mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Merge branch 'master' into poc_token_auth
* master: (156 commits) Fixed issues with the sanitizie input in text panels, added docs, renamed config option build: removes arm32v6 docker image. Updated version in package.json to 6.0.0-pre1 Update CHANGELOG.md build: armv6 docker image. build: skips building rpm for armv6. build: builds for armv6. Explore: mini styling fix for angular query editors Removed unused props & state in PromQueryField chore: Remove logging and use the updated config param chore: Reverse sanitize variable so it defaults to false feat: wip: Sanitize user input on text panel fix: Text panel should re-render when panel mode is changed #14922 Minor rename of LogsProps and LogsState Splitted up LogLabels into LogLabelStats and LogLabel Refactored out LogRow to a separate file Removed strange edit Added link to side menu header and fixed styling Moved ValueMapping logic and tests to separate files Fixed data source selection in explore ...
This commit is contained in:
commit
31b5db06f1
@ -81,20 +81,9 @@ jobs:
|
||||
working_directory: /go/src/github.com/grafana/grafana
|
||||
steps:
|
||||
- checkout
|
||||
- run: 'go get -u github.com/alecthomas/gometalinter'
|
||||
- run: 'go get -u github.com/tsenart/deadcode'
|
||||
- run: 'go get -u github.com/jgautheron/goconst/cmd/goconst'
|
||||
- run: 'go get -u github.com/gordonklaus/ineffassign'
|
||||
- run: 'go get -u honnef.co/go/tools/cmd/megacheck'
|
||||
- run: 'go get -u github.com/opennota/check/cmd/structcheck'
|
||||
- run: 'go get -u github.com/mdempsky/unconvert'
|
||||
- run: 'go get -u github.com/opennota/check/cmd/varcheck'
|
||||
- run:
|
||||
name: run linters
|
||||
command: 'gometalinter --enable-gc --vendor --deadline 10m --disable-all --enable=deadcode --enable=goconst --enable=gofmt --enable=ineffassign --enable=megacheck --enable=structcheck --enable=unconvert --enable=varcheck ./...'
|
||||
- run:
|
||||
name: run go vet
|
||||
command: 'go vet ./pkg/...'
|
||||
name: Gometalinter tests
|
||||
command: './scripts/gometalinter.sh'
|
||||
|
||||
test-frontend:
|
||||
docker:
|
||||
@ -323,7 +312,7 @@ jobs:
|
||||
|
||||
deploy-enterprise-master:
|
||||
docker:
|
||||
- image: grafana/grafana-ci-deploy:1.1.0
|
||||
- image: grafana/grafana-ci-deploy:1.2.0
|
||||
steps:
|
||||
- attach_workspace:
|
||||
at: .
|
||||
@ -346,7 +335,7 @@ jobs:
|
||||
|
||||
deploy-enterprise-release:
|
||||
docker:
|
||||
- image: grafana/grafana-ci-deploy:1.1.0
|
||||
- image: grafana/grafana-ci-deploy:1.2.0
|
||||
steps:
|
||||
- attach_workspace:
|
||||
at: .
|
||||
@ -370,15 +359,15 @@ jobs:
|
||||
command: './scripts/build/load-signing-key.sh'
|
||||
- run:
|
||||
name: Update Debian repository
|
||||
command: './scripts/build/update_repo/update-deb.sh "enterprise" "$GPG_KEY_PASSWORD" "$CIRCLE_TAG"'
|
||||
command: './scripts/build/update_repo/update-deb.sh "enterprise" "$GPG_KEY_PASSWORD" "$CIRCLE_TAG" "enterprise-dist"'
|
||||
- run:
|
||||
name: Update RPM repository
|
||||
command: './scripts/build/update_repo/update-rpm.sh "enterprise" "$GPG_KEY_PASSWORD" "$CIRCLE_TAG"'
|
||||
command: './scripts/build/update_repo/update-rpm.sh "enterprise" "$GPG_KEY_PASSWORD" "$CIRCLE_TAG" "enterprise-dist"'
|
||||
|
||||
|
||||
deploy-master:
|
||||
docker:
|
||||
- image: grafana/grafana-ci-deploy:1.1.0
|
||||
- image: grafana/grafana-ci-deploy:1.2.0
|
||||
steps:
|
||||
- attach_workspace:
|
||||
at: .
|
||||
@ -408,7 +397,7 @@ jobs:
|
||||
|
||||
deploy-release:
|
||||
docker:
|
||||
- image: grafana/grafana-ci-deploy:1.1.0
|
||||
- image: grafana/grafana-ci-deploy:1.2.0
|
||||
steps:
|
||||
- checkout
|
||||
- attach_workspace:
|
||||
@ -433,10 +422,10 @@ jobs:
|
||||
command: './scripts/build/load-signing-key.sh'
|
||||
- run:
|
||||
name: Update Debian repository
|
||||
command: './scripts/build/update_repo/update-deb.sh "oss" "$GPG_KEY_PASSWORD" "$CIRCLE_TAG"'
|
||||
command: './scripts/build/update_repo/update-deb.sh "oss" "$GPG_KEY_PASSWORD" "$CIRCLE_TAG" "dist"'
|
||||
- run:
|
||||
name: Update RPM repository
|
||||
command: './scripts/build/update_repo/update-rpm.sh "oss" "$GPG_KEY_PASSWORD" "$CIRCLE_TAG"'
|
||||
command: './scripts/build/update_repo/update-rpm.sh "oss" "$GPG_KEY_PASSWORD" "$CIRCLE_TAG" "dist"'
|
||||
|
||||
workflows:
|
||||
version: 2
|
||||
|
10
CHANGELOG.md
10
CHANGELOG.md
@ -1,4 +1,4 @@
|
||||
# 5.5.0 (unreleased)
|
||||
# 6.0.0-beta1 (unreleased)
|
||||
|
||||
### New Features
|
||||
* **Alerting**: Adds support for Google Hangouts Chat notifications [#11221](https://github.com/grafana/grafana/issues/11221), thx [@PatrickSchuster](https://github.com/PatrickSchuster)
|
||||
@ -23,6 +23,14 @@
|
||||
|
||||
### Bug fixes
|
||||
* **Search**: Fix for issue with scrolling the "tags filter" dropdown, fixes [#14486](https://github.com/grafana/grafana/issues/14486)
|
||||
* **Prometheus**: Query for annotation always uses 60s step regardless of dashboard range, fixes [#14795](https://github.com/grafana/grafana/issues/14795)
|
||||
|
||||
### Breaking changes
|
||||
* **Text Panel**: The text panel does no longer by default allow unsantizied HTML.
|
||||
* [#4117](https://github.com/grafana/grafana/issues/4117). This means that if you have text panels with scripts tags
|
||||
* they will no longer work as before. To enable unsafe javascript execution in text panels enable the settings
|
||||
* `disable_sanitize_html` under the section `[panels]` in your Grafana ini file, or set env variable
|
||||
* `GF_PANELS_DISABLE_SANITIZE_HTML=true`.
|
||||
|
||||
# 5.4.3 (2019-01-14)
|
||||
|
||||
|
16
build.go
16
build.go
@ -46,6 +46,8 @@ var (
|
||||
binaries []string = []string{"grafana-server", "grafana-cli"}
|
||||
isDev bool = false
|
||||
enterprise bool = false
|
||||
skipRpmGen bool = false
|
||||
skipDebGen bool = false
|
||||
)
|
||||
|
||||
func main() {
|
||||
@ -67,6 +69,8 @@ func main() {
|
||||
flag.BoolVar(&enterprise, "enterprise", enterprise, "Build enterprise version of Grafana")
|
||||
flag.StringVar(&buildIdRaw, "buildId", "0", "Build ID from CI system")
|
||||
flag.BoolVar(&isDev, "dev", isDev, "optimal for development, skips certain steps")
|
||||
flag.BoolVar(&skipRpmGen, "skipRpm", skipRpmGen, "skip rpm package generation (default: false)")
|
||||
flag.BoolVar(&skipDebGen, "skipDeb", skipDebGen, "skip deb package generation (default: false)")
|
||||
flag.Parse()
|
||||
|
||||
buildId = shortenBuildId(buildIdRaw)
|
||||
@ -165,6 +169,7 @@ func makeLatestDistCopies() {
|
||||
".x86_64.rpm": "dist/grafana-latest-1.x86_64.rpm",
|
||||
".linux-amd64.tar.gz": "dist/grafana-latest.linux-x64.tar.gz",
|
||||
".linux-armv7.tar.gz": "dist/grafana-latest.linux-armv7.tar.gz",
|
||||
".linux-armv6.tar.gz": "dist/grafana-latest.linux-armv6.tar.gz",
|
||||
".linux-arm64.tar.gz": "dist/grafana-latest.linux-arm64.tar.gz",
|
||||
}
|
||||
|
||||
@ -239,6 +244,8 @@ func createDebPackages() {
|
||||
previousPkgArch := pkgArch
|
||||
if pkgArch == "armv7" {
|
||||
pkgArch = "armhf"
|
||||
} else if pkgArch == "armv6" {
|
||||
pkgArch = "armel"
|
||||
}
|
||||
createPackage(linuxPackageOptions{
|
||||
packageType: "deb",
|
||||
@ -289,8 +296,13 @@ func createRpmPackages() {
|
||||
}
|
||||
|
||||
func createLinuxPackages() {
|
||||
createDebPackages()
|
||||
createRpmPackages()
|
||||
if !skipDebGen {
|
||||
createDebPackages()
|
||||
}
|
||||
|
||||
if !skipRpmGen {
|
||||
createRpmPackages()
|
||||
}
|
||||
}
|
||||
|
||||
func createPackage(options linuxPackageOptions) {
|
||||
|
@ -590,6 +590,7 @@ callback_url =
|
||||
|
||||
[panels]
|
||||
enable_alpha = false
|
||||
disable_sanitize_html = false
|
||||
|
||||
[enterprise]
|
||||
license_path =
|
||||
|
@ -495,3 +495,8 @@ log_queries =
|
||||
# Path to a valid Grafana Enterprise license.jwt file
|
||||
;license_path =
|
||||
|
||||
[panels]
|
||||
;enable_alpha = false
|
||||
# If set to true Grafana will allow script tags in text panels. Not recommended as it enable XSS vulnerabilities.
|
||||
;disable_sanitize_html = false
|
||||
|
||||
|
1250
devenv/dev-dashboards/panel_tests_gauge.json
Normal file
1250
devenv/dev-dashboards/panel_tests_gauge.json
Normal file
File diff suppressed because it is too large
Load Diff
@ -47,7 +47,7 @@ authentication:
|
||||
|
||||
```bash
|
||||
[auth.gitlab]
|
||||
enabled = false
|
||||
enabled = true
|
||||
allow_sign_up = false
|
||||
client_id = GITLAB_APPLICATION_ID
|
||||
client_secret = GITLAB_SECRET
|
||||
|
@ -38,7 +38,7 @@ Name | Description
|
||||
|
||||
### IAM Roles
|
||||
|
||||
Currently all access to CloudWatch is done server side by the Grafana backend using the official AWS SDK. If you grafana
|
||||
Currently all access to CloudWatch is done server side by the Grafana backend using the official AWS SDK. If your Grafana
|
||||
server is running on AWS you can use IAM Roles and authentication will be handled automatically.
|
||||
|
||||
Checkout AWS docs on [IAM Roles](http://docs.aws.amazon.com/AWSEC2/latest/UserGuide/iam-roles-for-amazon-ec2.html)
|
||||
|
@ -589,3 +589,14 @@ Default setting for how Grafana handles nodata or null values in alerting. (aler
|
||||
Alert notifications can include images, but rendering many images at the same time can overload the server.
|
||||
This limit will protect the server from render overloading and make sure notifications are sent out quickly. Default
|
||||
value is `5`.
|
||||
|
||||
## [panels]
|
||||
|
||||
### enable_alpha
|
||||
Set to true if you want to test panels that are not yet ready for general usage.
|
||||
|
||||
### disable_sanitize_html
|
||||
If set to true Grafana will allow script tags in text panels. Not recommended as it enable XSS vulnerabilities. Default
|
||||
is false. This settings was introduced in Grafana v6.0.
|
||||
|
||||
|
||||
|
@ -5,7 +5,7 @@
|
||||
"company": "Grafana Labs"
|
||||
},
|
||||
"name": "grafana",
|
||||
"version": "5.5.0-pre1",
|
||||
"version": "6.0.0-pre1",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "http://github.com/grafana/grafana.git"
|
||||
@ -188,7 +188,8 @@
|
||||
"slate-react": "^0.12.4",
|
||||
"tether": "^1.4.0",
|
||||
"tether-drop": "https://github.com/torkelo/drop/tarball/master",
|
||||
"tinycolor2": "^1.4.1"
|
||||
"tinycolor2": "^1.4.1",
|
||||
"xss": "^1.0.3"
|
||||
},
|
||||
"resolutions": {
|
||||
"caniuse-db": "1.0.30000772",
|
||||
|
@ -1,7 +1,7 @@
|
||||
import React from 'react';
|
||||
import _ from 'lodash';
|
||||
import $ from 'jquery';
|
||||
import 'vendor/spectrum';
|
||||
import '../../vendor/spectrum';
|
||||
|
||||
export interface Props {
|
||||
color: string;
|
||||
|
@ -1,4 +1,5 @@
|
||||
import React, { PureComponent } from 'react';
|
||||
import _ from 'lodash';
|
||||
import Scrollbars from 'react-custom-scrollbars';
|
||||
|
||||
interface Props {
|
||||
@ -6,7 +7,11 @@ interface Props {
|
||||
autoHide?: boolean;
|
||||
autoHideTimeout?: number;
|
||||
autoHideDuration?: number;
|
||||
autoMaxHeight?: string;
|
||||
hideTracksWhenNotNeeded?: boolean;
|
||||
scrollTop?: number;
|
||||
setScrollTop: (value: React.MouseEvent<HTMLElement>) => void;
|
||||
autoHeightMin?: number | string;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -18,26 +23,55 @@ export class CustomScrollbar extends PureComponent<Props> {
|
||||
autoHide: true,
|
||||
autoHideTimeout: 200,
|
||||
autoHideDuration: 200,
|
||||
autoMaxHeight: '100%',
|
||||
hideTracksWhenNotNeeded: false,
|
||||
setScrollTop: () => {},
|
||||
autoHeightMin: '0'
|
||||
};
|
||||
|
||||
private ref: React.RefObject<Scrollbars>;
|
||||
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
this.ref = React.createRef<Scrollbars>();
|
||||
}
|
||||
|
||||
updateScroll() {
|
||||
const ref = this.ref.current;
|
||||
|
||||
if (ref && !_.isNil(this.props.scrollTop)) {
|
||||
if (this.props.scrollTop > 10000) {
|
||||
ref.scrollToBottom();
|
||||
} else {
|
||||
ref.scrollTop(this.props.scrollTop);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.updateScroll();
|
||||
}
|
||||
|
||||
componentDidUpdate() {
|
||||
this.updateScroll();
|
||||
}
|
||||
|
||||
render() {
|
||||
const { customClassName, children, ...scrollProps } = this.props;
|
||||
const { customClassName, children, autoMaxHeight } = this.props;
|
||||
|
||||
return (
|
||||
<Scrollbars
|
||||
ref={this.ref}
|
||||
className={customClassName}
|
||||
autoHeight={true}
|
||||
// These autoHeightMin & autoHeightMax options affect firefox and chrome differently.
|
||||
// Before these where set to inhert but that caused problems with cut of legends in firefox
|
||||
autoHeightMin={'0'}
|
||||
autoHeightMax={'100%'}
|
||||
autoHeightMax={autoMaxHeight}
|
||||
renderTrackHorizontal={props => <div {...props} className="track-horizontal" />}
|
||||
renderTrackVertical={props => <div {...props} className="track-vertical" />}
|
||||
renderThumbHorizontal={props => <div {...props} className="thumb-horizontal" />}
|
||||
renderThumbVertical={props => <div {...props} className="thumb-vertical" />}
|
||||
renderView={props => <div {...props} className="view" />}
|
||||
{...scrollProps}
|
||||
>
|
||||
{children}
|
||||
</Scrollbars>
|
||||
|
@ -7,7 +7,7 @@ exports[`CustomScrollbar renders correctly 1`] = `
|
||||
Object {
|
||||
"height": "auto",
|
||||
"maxHeight": "100%",
|
||||
"minHeight": "0",
|
||||
"minHeight": 0,
|
||||
"overflow": "hidden",
|
||||
"position": "relative",
|
||||
"width": "100%",
|
||||
@ -24,7 +24,7 @@ exports[`CustomScrollbar renders correctly 1`] = `
|
||||
"marginBottom": 0,
|
||||
"marginRight": 0,
|
||||
"maxHeight": "calc(100% + 0px)",
|
||||
"minHeight": "calc(0 + 0px)",
|
||||
"minHeight": 0,
|
||||
"overflow": "scroll",
|
||||
"position": "relative",
|
||||
"right": undefined,
|
||||
@ -42,9 +42,7 @@ exports[`CustomScrollbar renders correctly 1`] = `
|
||||
Object {
|
||||
"display": "none",
|
||||
"height": 6,
|
||||
"opacity": 0,
|
||||
"position": "absolute",
|
||||
"transition": "opacity 200ms",
|
||||
}
|
||||
}
|
||||
>
|
||||
@ -64,9 +62,7 @@ exports[`CustomScrollbar renders correctly 1`] = `
|
||||
style={
|
||||
Object {
|
||||
"display": "none",
|
||||
"opacity": 0,
|
||||
"position": "absolute",
|
||||
"transition": "opacity 200ms",
|
||||
"width": 6,
|
||||
}
|
||||
}
|
||||
|
@ -0,0 +1,24 @@
|
||||
import React from 'react';
|
||||
import { shallow } from 'enzyme';
|
||||
import { FormField, Props } from './FormField';
|
||||
|
||||
const setup = (propOverrides?: object) => {
|
||||
const props: Props = {
|
||||
label: 'Test',
|
||||
labelWidth: 11,
|
||||
value: 10,
|
||||
onChange: jest.fn(),
|
||||
};
|
||||
|
||||
Object.assign(props, propOverrides);
|
||||
|
||||
return shallow(<FormField {...props} />);
|
||||
};
|
||||
|
||||
describe('Render', () => {
|
||||
it('should render component', () => {
|
||||
const wrapper = setup();
|
||||
|
||||
expect(wrapper).toMatchSnapshot();
|
||||
});
|
||||
});
|
25
packages/grafana-ui/src/components/FormField/FormField.tsx
Normal file
25
packages/grafana-ui/src/components/FormField/FormField.tsx
Normal file
@ -0,0 +1,25 @@
|
||||
import React, { InputHTMLAttributes, FunctionComponent } from 'react';
|
||||
import { FormLabel } from '..';
|
||||
|
||||
export interface Props extends InputHTMLAttributes<HTMLInputElement> {
|
||||
label: string;
|
||||
labelWidth?: number;
|
||||
inputWidth?: number;
|
||||
}
|
||||
|
||||
const defaultProps = {
|
||||
labelWidth: 6,
|
||||
inputWidth: 12,
|
||||
};
|
||||
|
||||
const FormField: FunctionComponent<Props> = ({ label, labelWidth, inputWidth, ...inputProps }) => {
|
||||
return (
|
||||
<div className="form-field">
|
||||
<FormLabel width={labelWidth}>{label}</FormLabel>
|
||||
<input type="text" className={`gf-form-input width-${inputWidth}`} {...inputProps} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
FormField.defaultProps = defaultProps;
|
||||
export { FormField };
|
12
packages/grafana-ui/src/components/FormField/_FormField.scss
Normal file
12
packages/grafana-ui/src/components/FormField/_FormField.scss
Normal file
@ -0,0 +1,12 @@
|
||||
.form-field {
|
||||
margin-bottom: $gf-form-margin;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
text-align: left;
|
||||
position: relative;
|
||||
|
||||
&--grow {
|
||||
flex-grow: 1;
|
||||
}
|
||||
}
|
@ -0,0 +1,19 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`Render should render component 1`] = `
|
||||
<div
|
||||
className="form-field"
|
||||
>
|
||||
<Component
|
||||
width={11}
|
||||
>
|
||||
Test
|
||||
</Component>
|
||||
<input
|
||||
className="gf-form-input width-12"
|
||||
onChange={[MockFunction]}
|
||||
type="text"
|
||||
value={10}
|
||||
/>
|
||||
</div>
|
||||
`;
|
42
packages/grafana-ui/src/components/FormLabel/FormLabel.tsx
Normal file
42
packages/grafana-ui/src/components/FormLabel/FormLabel.tsx
Normal file
@ -0,0 +1,42 @@
|
||||
import React, { FunctionComponent, ReactNode } from 'react';
|
||||
import classNames from 'classnames';
|
||||
import { Tooltip } from '..';
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
htmlFor?: string;
|
||||
isFocused?: boolean;
|
||||
isInvalid?: boolean;
|
||||
tooltip?: string;
|
||||
width?: number;
|
||||
}
|
||||
|
||||
export const FormLabel: FunctionComponent<Props> = ({
|
||||
children,
|
||||
isFocused,
|
||||
isInvalid,
|
||||
className,
|
||||
htmlFor,
|
||||
tooltip,
|
||||
width,
|
||||
...rest
|
||||
}) => {
|
||||
const classes = classNames(`gf-form-label width-${width ? width : '10'}`, className, {
|
||||
'gf-form-label--is-focused': isFocused,
|
||||
'gf-form-label--is-invalid': isInvalid,
|
||||
});
|
||||
|
||||
return (
|
||||
<label className={classes} {...rest} htmlFor={htmlFor}>
|
||||
{children}
|
||||
{tooltip && (
|
||||
<Tooltip placement="auto" content={tooltip}>
|
||||
<div className="gf-form-help-icon--right-normal">
|
||||
<i className="gicon gicon-question gicon--has-hover" />
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
</label>
|
||||
);
|
||||
};
|
147
packages/grafana-ui/src/components/Gauge/Gauge.test.tsx
Normal file
147
packages/grafana-ui/src/components/Gauge/Gauge.test.tsx
Normal file
@ -0,0 +1,147 @@
|
||||
import React from 'react';
|
||||
import { shallow } from 'enzyme';
|
||||
|
||||
import { Gauge, Props } from './Gauge';
|
||||
import { TimeSeriesVMs } from '../../types/series';
|
||||
import { ValueMapping, MappingType } from '../../types';
|
||||
|
||||
jest.mock('jquery', () => ({
|
||||
plot: jest.fn(),
|
||||
}));
|
||||
|
||||
const setup = (propOverrides?: object) => {
|
||||
const props: Props = {
|
||||
maxValue: 100,
|
||||
valueMappings: [],
|
||||
minValue: 0,
|
||||
prefix: '',
|
||||
showThresholdMarkers: true,
|
||||
showThresholdLabels: false,
|
||||
suffix: '',
|
||||
thresholds: [{ index: 0, value: -Infinity, color: '#7EB26D' }],
|
||||
unit: 'none',
|
||||
stat: 'avg',
|
||||
height: 300,
|
||||
width: 300,
|
||||
timeSeries: {} as TimeSeriesVMs,
|
||||
decimals: 0,
|
||||
};
|
||||
|
||||
Object.assign(props, propOverrides);
|
||||
|
||||
const wrapper = shallow(<Gauge {...props} />);
|
||||
const instance = wrapper.instance() as Gauge;
|
||||
|
||||
return {
|
||||
instance,
|
||||
wrapper,
|
||||
};
|
||||
};
|
||||
|
||||
describe('Get font color', () => {
|
||||
it('should get first threshold color when only one threshold', () => {
|
||||
const { instance } = setup({ thresholds: [{ index: 0, value: -Infinity, color: '#7EB26D' }] });
|
||||
|
||||
expect(instance.getFontColor(49)).toEqual('#7EB26D');
|
||||
});
|
||||
|
||||
it('should get the threshold color if value is same as a threshold', () => {
|
||||
const { instance } = setup({
|
||||
thresholds: [
|
||||
{ index: 2, value: 75, color: '#6ED0E0' },
|
||||
{ index: 1, value: 50, color: '#EAB839' },
|
||||
{ index: 0, value: -Infinity, color: '#7EB26D' },
|
||||
],
|
||||
});
|
||||
|
||||
expect(instance.getFontColor(50)).toEqual('#EAB839');
|
||||
});
|
||||
|
||||
it('should get the nearest threshold color between thresholds', () => {
|
||||
const { instance } = setup({
|
||||
thresholds: [
|
||||
{ index: 2, value: 75, color: '#6ED0E0' },
|
||||
{ index: 1, value: 50, color: '#EAB839' },
|
||||
{ index: 0, value: -Infinity, color: '#7EB26D' },
|
||||
],
|
||||
});
|
||||
|
||||
expect(instance.getFontColor(55)).toEqual('#EAB839');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Get thresholds formatted', () => {
|
||||
it('should return first thresholds color for min and max', () => {
|
||||
const { instance } = setup({ thresholds: [{ index: 0, value: -Infinity, color: '#7EB26D' }] });
|
||||
|
||||
expect(instance.getFormattedThresholds()).toEqual([
|
||||
{ value: 0, color: '#7EB26D' },
|
||||
{ value: 100, color: '#7EB26D' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should get the correct formatted values when thresholds are added', () => {
|
||||
const { instance } = setup({
|
||||
thresholds: [
|
||||
{ index: 2, value: 75, color: '#6ED0E0' },
|
||||
{ index: 1, value: 50, color: '#EAB839' },
|
||||
{ index: 0, value: -Infinity, color: '#7EB26D' },
|
||||
],
|
||||
});
|
||||
|
||||
expect(instance.getFormattedThresholds()).toEqual([
|
||||
{ value: 0, color: '#7EB26D' },
|
||||
{ value: 50, color: '#7EB26D' },
|
||||
{ value: 75, color: '#EAB839' },
|
||||
{ value: 100, color: '#6ED0E0' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Format value', () => {
|
||||
it('should return if value isNaN', () => {
|
||||
const valueMappings: ValueMapping[] = [];
|
||||
const value = 'N/A';
|
||||
const { instance } = setup({ valueMappings });
|
||||
|
||||
const result = instance.formatValue(value);
|
||||
|
||||
expect(result).toEqual('N/A');
|
||||
});
|
||||
|
||||
it('should return formatted value if there are no value mappings', () => {
|
||||
const valueMappings: ValueMapping[] = [];
|
||||
const value = '6';
|
||||
const { instance } = setup({ valueMappings, decimals: 1 });
|
||||
|
||||
const result = instance.formatValue(value);
|
||||
|
||||
expect(result).toEqual(' 6.0 ');
|
||||
});
|
||||
|
||||
it('should return formatted value if there are no matching value mappings', () => {
|
||||
const valueMappings: ValueMapping[] = [
|
||||
{ id: 0, operator: '', text: 'elva', type: MappingType.ValueToText, value: '11' },
|
||||
{ id: 1, operator: '', text: '1-9', type: MappingType.RangeToText, from: '1', to: '9' },
|
||||
];
|
||||
const value = '10';
|
||||
const { instance } = setup({ valueMappings, decimals: 1 });
|
||||
|
||||
const result = instance.formatValue(value);
|
||||
|
||||
expect(result).toEqual(' 10.0 ');
|
||||
});
|
||||
|
||||
it('should return mapped value if there are matching value mappings', () => {
|
||||
const valueMappings: ValueMapping[] = [
|
||||
{ id: 0, operator: '', text: '1-20', type: MappingType.RangeToText, from: '1', to: '20' },
|
||||
{ id: 1, operator: '', text: 'elva', type: MappingType.ValueToText, value: '11' },
|
||||
];
|
||||
const value = '11';
|
||||
const { instance } = setup({ valueMappings, decimals: 1 });
|
||||
|
||||
const result = instance.formatValue(value);
|
||||
|
||||
expect(result).toEqual(' 1-20 ');
|
||||
});
|
||||
});
|
@ -1,15 +1,15 @@
|
||||
import React, { PureComponent } from 'react';
|
||||
import $ from 'jquery';
|
||||
import { BasicGaugeColor, Threshold, TimeSeriesVMs, RangeMap, ValueMap, MappingType } from '@grafana/ui';
|
||||
|
||||
import config from '../core/config';
|
||||
import kbn from '../core/utils/kbn';
|
||||
import { ValueMapping, Threshold, ThemeName, BasicGaugeColor, ThemeNames } from '../../types/panel';
|
||||
import { TimeSeriesVMs } from '../../types/series';
|
||||
import { getValueFormat } from '../../utils/valueFormats/valueFormats';
|
||||
import { TimeSeriesValue, getMappedValue } from '../../utils/valueMappings';
|
||||
|
||||
export interface Props {
|
||||
baseColor: string;
|
||||
decimals: number;
|
||||
height: number;
|
||||
mappings: Array<RangeMap | ValueMap>;
|
||||
valueMappings: ValueMapping[];
|
||||
maxValue: number;
|
||||
minValue: number;
|
||||
prefix: string;
|
||||
@ -21,15 +21,15 @@ export interface Props {
|
||||
suffix: string;
|
||||
unit: string;
|
||||
width: number;
|
||||
theme?: ThemeName;
|
||||
}
|
||||
|
||||
export class Gauge extends PureComponent<Props> {
|
||||
canvasElement: any;
|
||||
|
||||
static defaultProps = {
|
||||
baseColor: BasicGaugeColor.Green,
|
||||
maxValue: 100,
|
||||
mappings: [],
|
||||
valueMappings: [],
|
||||
minValue: 0,
|
||||
prefix: '',
|
||||
showThresholdMarkers: true,
|
||||
@ -38,6 +38,7 @@ export class Gauge extends PureComponent<Props> {
|
||||
thresholds: [],
|
||||
unit: 'none',
|
||||
stat: 'avg',
|
||||
theme: ThemeNames.Dark,
|
||||
};
|
||||
|
||||
componentDidMount() {
|
||||
@ -48,91 +49,93 @@ export class Gauge extends PureComponent<Props> {
|
||||
this.draw();
|
||||
}
|
||||
|
||||
formatWithMappings(mappings, value) {
|
||||
const valueMaps = mappings.filter(m => m.type === MappingType.ValueToText);
|
||||
const rangeMaps = mappings.filter(m => m.type === MappingType.RangeToText);
|
||||
formatValue(value: TimeSeriesValue) {
|
||||
const { decimals, valueMappings, prefix, suffix, unit } = this.props;
|
||||
|
||||
const valueMap = valueMaps.map(mapping => {
|
||||
if (mapping.value && value === mapping.value) {
|
||||
return mapping.text;
|
||||
if (isNaN(value as number)) {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (valueMappings.length > 0) {
|
||||
const valueMappedValue = getMappedValue(valueMappings, value);
|
||||
if (valueMappedValue) {
|
||||
return `${prefix} ${valueMappedValue.text} ${suffix}`;
|
||||
}
|
||||
})[0];
|
||||
}
|
||||
|
||||
const rangeMap = rangeMaps.map(mapping => {
|
||||
if (mapping.from && mapping.to && value > mapping.from && value < mapping.to) {
|
||||
return mapping.text;
|
||||
}
|
||||
})[0];
|
||||
const formatFunc = getValueFormat(unit);
|
||||
const formattedValue = formatFunc(value as number, decimals);
|
||||
const handleNoValueValue = formattedValue || 'no value';
|
||||
|
||||
return {
|
||||
rangeMap,
|
||||
valueMap,
|
||||
};
|
||||
return `${prefix} ${handleNoValueValue} ${suffix}`;
|
||||
}
|
||||
|
||||
formatValue(value) {
|
||||
const { decimals, mappings, prefix, suffix, unit } = this.props;
|
||||
getFontColor(value: TimeSeriesValue) {
|
||||
const { thresholds } = this.props;
|
||||
|
||||
const formatFunc = kbn.valueFormats[unit];
|
||||
const formattedValue = formatFunc(value, decimals);
|
||||
|
||||
if (mappings.length > 0) {
|
||||
const { rangeMap, valueMap } = this.formatWithMappings(mappings, formattedValue);
|
||||
|
||||
if (valueMap) {
|
||||
return valueMap;
|
||||
} else if (rangeMap) {
|
||||
return rangeMap;
|
||||
}
|
||||
if (thresholds.length === 1) {
|
||||
return thresholds[0].color;
|
||||
}
|
||||
|
||||
if (isNaN(value)) {
|
||||
return '-';
|
||||
const atThreshold = thresholds.filter(threshold => (value as number) === threshold.value)[0];
|
||||
if (atThreshold) {
|
||||
return atThreshold.color;
|
||||
}
|
||||
|
||||
return `${prefix} ${formattedValue} ${suffix}`;
|
||||
const belowThreshold = thresholds.filter(threshold => (value as number) > threshold.value);
|
||||
|
||||
if (belowThreshold.length > 0) {
|
||||
const nearestThreshold = belowThreshold.sort((t1, t2) => t2.value - t1.value)[0];
|
||||
return nearestThreshold.color;
|
||||
}
|
||||
|
||||
return BasicGaugeColor.Red;
|
||||
}
|
||||
|
||||
getFontColor(value) {
|
||||
const { baseColor, maxValue, thresholds } = this.props;
|
||||
getFormattedThresholds() {
|
||||
const { maxValue, minValue, thresholds } = this.props;
|
||||
|
||||
if (thresholds.length > 0) {
|
||||
const atThreshold = thresholds.filter(threshold => value <= threshold.value);
|
||||
const thresholdsSortedByIndex = [...thresholds].sort((t1, t2) => t1.index - t2.index);
|
||||
const lastThreshold = thresholdsSortedByIndex[thresholdsSortedByIndex.length - 1];
|
||||
|
||||
if (atThreshold.length > 0) {
|
||||
return atThreshold[0].color;
|
||||
} else if (value <= maxValue) {
|
||||
return BasicGaugeColor.Red;
|
||||
}
|
||||
}
|
||||
const formattedThresholds = [
|
||||
...thresholdsSortedByIndex.map(threshold => {
|
||||
if (threshold.index === 0) {
|
||||
return { value: minValue, color: threshold.color };
|
||||
}
|
||||
|
||||
return baseColor;
|
||||
const previousThreshold = thresholdsSortedByIndex[threshold.index - 1];
|
||||
return { value: threshold.value, color: previousThreshold.color };
|
||||
}),
|
||||
{ value: maxValue, color: lastThreshold.color },
|
||||
];
|
||||
|
||||
return formattedThresholds;
|
||||
}
|
||||
|
||||
draw() {
|
||||
const {
|
||||
baseColor,
|
||||
maxValue,
|
||||
minValue,
|
||||
timeSeries,
|
||||
showThresholdLabels,
|
||||
showThresholdMarkers,
|
||||
thresholds,
|
||||
width,
|
||||
height,
|
||||
stat,
|
||||
theme,
|
||||
} = this.props;
|
||||
|
||||
let value: string | number = '';
|
||||
let value: TimeSeriesValue = '';
|
||||
|
||||
if (timeSeries[0]) {
|
||||
value = timeSeries[0].stats[stat];
|
||||
} else {
|
||||
value = 'N/A';
|
||||
value = null;
|
||||
}
|
||||
|
||||
const dimension = Math.min(width, height * 1.3);
|
||||
const backgroundColor = config.bootData.user.lightTheme ? 'rgb(230,230,230)' : 'rgb(38,38,38)';
|
||||
const backgroundColor = theme === ThemeNames.Light ? 'rgb(230,230,230)' : 'rgb(38,38,38)';
|
||||
const fontScale = parseInt('80', 10) / 100;
|
||||
const fontSize = Math.min(dimension / 5, 100) * fontScale;
|
||||
const gaugeWidthReduceRatio = showThresholdLabels ? 1.5 : 1;
|
||||
@ -140,20 +143,6 @@ export class Gauge extends PureComponent<Props> {
|
||||
const thresholdMarkersWidth = gaugeWidth / 5;
|
||||
const thresholdLabelFontSize = fontSize / 2.5;
|
||||
|
||||
const formattedThresholds = [
|
||||
{ value: minValue, color: BasicGaugeColor.Green },
|
||||
...thresholds.map((threshold, index) => {
|
||||
return {
|
||||
value: threshold.value,
|
||||
color: index === 0 ? threshold.color : thresholds[index].color,
|
||||
};
|
||||
}),
|
||||
{
|
||||
value: maxValue,
|
||||
color: thresholds.length > 0 ? BasicGaugeColor.Red : baseColor,
|
||||
},
|
||||
];
|
||||
|
||||
const options = {
|
||||
series: {
|
||||
gauges: {
|
||||
@ -170,7 +159,7 @@ export class Gauge extends PureComponent<Props> {
|
||||
layout: { margin: 0, thresholdWidth: 0 },
|
||||
cell: { border: { width: 0 } },
|
||||
threshold: {
|
||||
values: formattedThresholds,
|
||||
values: this.getFormattedThresholds(),
|
||||
label: {
|
||||
show: showThresholdLabels,
|
||||
margin: thresholdMarkersWidth + 1,
|
||||
@ -184,19 +173,14 @@ export class Gauge extends PureComponent<Props> {
|
||||
formatter: () => {
|
||||
return this.formatValue(value);
|
||||
},
|
||||
font: {
|
||||
size: fontSize,
|
||||
family: '"Helvetica Neue", Helvetica, Arial, sans-serif',
|
||||
},
|
||||
font: { size: fontSize, family: '"Helvetica Neue", Helvetica, Arial, sans-serif' },
|
||||
},
|
||||
show: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const plotSeries = {
|
||||
data: [[0, value]],
|
||||
};
|
||||
const plotSeries = { data: [[0, value]] };
|
||||
|
||||
try {
|
||||
$.plot(this.canvasElement, [plotSeries], options);
|
@ -1,23 +0,0 @@
|
||||
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>
|
||||
);
|
||||
};
|
@ -6,7 +6,7 @@
|
||||
}
|
||||
|
||||
.panel-options-group__header {
|
||||
padding: 4px 20px;
|
||||
padding: 4px 8px;
|
||||
font-size: 1.1rem;
|
||||
background: $panel-options-group-header-bg;
|
||||
position: relative;
|
||||
|
@ -16,7 +16,7 @@ import SelectOptionGroup from './SelectOptionGroup';
|
||||
import IndicatorsContainer from './IndicatorsContainer';
|
||||
import NoOptionsMessage from './NoOptionsMessage';
|
||||
import resetSelectStyles from './resetSelectStyles';
|
||||
import { CustomScrollbar } from '@grafana/ui';
|
||||
import { CustomScrollbar } from '..';
|
||||
|
||||
export interface SelectOptionItem {
|
||||
label?: string;
|
||||
@ -61,7 +61,7 @@ interface AsyncProps {
|
||||
export const MenuList = (props: any) => {
|
||||
return (
|
||||
<components.MenuList {...props}>
|
||||
<CustomScrollbar autoHide={false}>{props.children}</CustomScrollbar>
|
||||
<CustomScrollbar autoHide={false} autoMaxHeight="inherit">{props.children}</CustomScrollbar>
|
||||
</components.MenuList>
|
||||
);
|
||||
};
|
||||
@ -202,7 +202,7 @@ export class AsyncSelect extends PureComponent<CommonProps & AsyncProps> {
|
||||
classNamePrefix="gf-form-select-box"
|
||||
className={selectClassNames}
|
||||
components={{
|
||||
Option,
|
||||
Option: SelectOption,
|
||||
SingleValue,
|
||||
IndicatorsContainer,
|
||||
NoOptionsMessage,
|
||||
|
@ -102,6 +102,7 @@ $select-input-bg-disabled: $input-bg-disabled;
|
||||
.gf-form-select-box__value-container {
|
||||
display: table-cell;
|
||||
padding: 6px 10px;
|
||||
vertical-align: middle;
|
||||
> div {
|
||||
display: inline-block;
|
||||
}
|
||||
|
@ -2,7 +2,6 @@ import React from 'react';
|
||||
import { shallow } from 'enzyme';
|
||||
|
||||
import { ThresholdsEditor, Props } from './ThresholdsEditor';
|
||||
import { BasicGaugeColor } from '../../types';
|
||||
|
||||
const setup = (propOverrides?: object) => {
|
||||
const props: Props = {
|
||||
@ -15,49 +14,160 @@ const setup = (propOverrides?: object) => {
|
||||
return shallow(<ThresholdsEditor {...props} />).instance() as ThresholdsEditor;
|
||||
};
|
||||
|
||||
describe('Initialization', () => {
|
||||
it('should add a base threshold if missing', () => {
|
||||
const instance = setup();
|
||||
|
||||
expect(instance.state.thresholds).toEqual([{ index: 0, value: -Infinity, color: '#7EB26D' }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Add threshold', () => {
|
||||
it('should add threshold', () => {
|
||||
it('should not add threshold at index 0', () => {
|
||||
const instance = setup();
|
||||
|
||||
instance.onAddThreshold(0);
|
||||
|
||||
expect(instance.state.thresholds).toEqual([{ index: 0, value: 50, color: 'rgb(127, 115, 64)' }]);
|
||||
expect(instance.state.thresholds).toEqual([{ index: 0, value: -Infinity, color: '#7EB26D' }]);
|
||||
});
|
||||
|
||||
it('should add another threshold above a first', () => {
|
||||
const instance = setup({
|
||||
thresholds: [{ index: 0, value: 50, color: 'rgb(127, 115, 64)' }],
|
||||
});
|
||||
it('should add threshold', () => {
|
||||
const instance = setup();
|
||||
|
||||
instance.onAddThreshold(1);
|
||||
|
||||
expect(instance.state.thresholds).toEqual([
|
||||
{ index: 1, value: 75, color: 'rgb(170, 95, 61)' },
|
||||
{ index: 0, value: 50, color: 'rgb(127, 115, 64)' },
|
||||
{ index: 1, value: 50, color: '#EAB839' },
|
||||
{ index: 0, value: -Infinity, color: '#7EB26D' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should add another threshold above a first', () => {
|
||||
const instance = setup({
|
||||
thresholds: [{ index: 0, value: -Infinity, color: '#7EB26D' }, { index: 1, value: 50, color: '#EAB839' }],
|
||||
});
|
||||
|
||||
instance.onAddThreshold(2);
|
||||
|
||||
expect(instance.state.thresholds).toEqual([
|
||||
{ index: 2, value: 75, color: '#6ED0E0' },
|
||||
{ index: 1, value: 50, color: '#EAB839' },
|
||||
{ index: 0, value: -Infinity, color: '#7EB26D' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should add another threshold between first and second index', () => {
|
||||
const instance = setup({
|
||||
thresholds: [
|
||||
{ index: 0, value: -Infinity, color: '#7EB26D' },
|
||||
{ index: 1, value: 50, color: '#EAB839' },
|
||||
{ index: 2, value: 75, color: '#6ED0E0' },
|
||||
],
|
||||
});
|
||||
|
||||
instance.onAddThreshold(2);
|
||||
|
||||
expect(instance.state.thresholds).toEqual([
|
||||
{ index: 3, value: 75, color: '#6ED0E0' },
|
||||
{ index: 2, value: 62.5, color: '#EF843C' },
|
||||
{ index: 1, value: 50, color: '#EAB839' },
|
||||
{ index: 0, value: -Infinity, color: '#7EB26D' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Remove threshold', () => {
|
||||
it('should not remove threshold at index 0', () => {
|
||||
const thresholds = [
|
||||
{ index: 0, value: -Infinity, color: '#7EB26D' },
|
||||
{ index: 1, value: 50, color: '#EAB839' },
|
||||
{ index: 2, value: 75, color: '#6ED0E0' },
|
||||
];
|
||||
const instance = setup({ thresholds });
|
||||
|
||||
instance.onRemoveThreshold(thresholds[0]);
|
||||
|
||||
expect(instance.state.thresholds).toEqual(thresholds);
|
||||
});
|
||||
|
||||
it('should remove threshold', () => {
|
||||
const thresholds = [
|
||||
{ index: 0, value: -Infinity, color: '#7EB26D' },
|
||||
{ index: 1, value: 50, color: '#EAB839' },
|
||||
{ index: 2, value: 75, color: '#6ED0E0' },
|
||||
];
|
||||
const instance = setup({
|
||||
thresholds,
|
||||
});
|
||||
|
||||
instance.onRemoveThreshold(thresholds[1]);
|
||||
|
||||
expect(instance.state.thresholds).toEqual([
|
||||
{ index: 0, value: -Infinity, color: '#7EB26D' },
|
||||
{ index: 1, value: 75, color: '#6ED0E0' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('change threshold value', () => {
|
||||
it('should update value and resort rows', () => {
|
||||
it('should not change threshold at index 0', () => {
|
||||
const thresholds = [
|
||||
{ index: 0, value: -Infinity, color: '#7EB26D' },
|
||||
{ index: 1, value: 50, color: '#EAB839' },
|
||||
{ index: 2, value: 75, color: '#6ED0E0' },
|
||||
];
|
||||
const instance = setup({ thresholds });
|
||||
|
||||
const mockEvent = { target: { value: 12 } };
|
||||
|
||||
instance.onChangeThresholdValue(mockEvent, thresholds[0]);
|
||||
|
||||
expect(instance.state.thresholds).toEqual(thresholds);
|
||||
});
|
||||
|
||||
it('should update value', () => {
|
||||
const instance = setup();
|
||||
const mockThresholds = [
|
||||
{ index: 0, value: 50, color: 'rgba(237, 129, 40, 0.89)' },
|
||||
{ index: 1, value: 75, color: 'rgba(237, 129, 40, 0.89)' },
|
||||
const thresholds = [
|
||||
{ index: 0, value: -Infinity, color: '#7EB26D' },
|
||||
{ index: 1, value: 50, color: '#EAB839' },
|
||||
{ index: 2, value: 75, color: '#6ED0E0' },
|
||||
];
|
||||
|
||||
instance.state = {
|
||||
baseColor: BasicGaugeColor.Green,
|
||||
thresholds: mockThresholds,
|
||||
thresholds,
|
||||
};
|
||||
|
||||
const mockEvent = { target: { value: 78 } };
|
||||
|
||||
instance.onChangeThresholdValue(mockEvent, mockThresholds[0]);
|
||||
instance.onChangeThresholdValue(mockEvent, thresholds[1]);
|
||||
|
||||
expect(instance.state.thresholds).toEqual([
|
||||
{ index: 0, value: 78, color: 'rgba(237, 129, 40, 0.89)' },
|
||||
{ index: 1, value: 75, color: 'rgba(237, 129, 40, 0.89)' },
|
||||
{ index: 0, value: -Infinity, color: '#7EB26D' },
|
||||
{ index: 1, value: 78, color: '#EAB839' },
|
||||
{ index: 2, value: 75, color: '#6ED0E0' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('on blur threshold value', () => {
|
||||
it('should resort rows and update indexes', () => {
|
||||
const instance = setup();
|
||||
const thresholds = [
|
||||
{ index: 0, value: -Infinity, color: '#7EB26D' },
|
||||
{ index: 1, value: 78, color: '#EAB839' },
|
||||
{ index: 2, value: 75, color: '#6ED0E0' },
|
||||
];
|
||||
|
||||
instance.state = {
|
||||
thresholds,
|
||||
};
|
||||
|
||||
instance.onBlur();
|
||||
|
||||
expect(instance.state.thresholds).toEqual([
|
||||
{ index: 2, value: 78, color: '#EAB839' },
|
||||
{ index: 1, value: 75, color: '#6ED0E0' },
|
||||
{ index: 0, value: -Infinity, color: '#7EB26D' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
@ -1,9 +1,10 @@
|
||||
import React, { PureComponent } from 'react';
|
||||
import tinycolor, { ColorInput } from 'tinycolor2';
|
||||
// import tinycolor, { ColorInput } from 'tinycolor2';
|
||||
|
||||
import { Threshold, BasicGaugeColor } from '../../types';
|
||||
import { Threshold } from '../../types';
|
||||
import { ColorPicker } from '../ColorPicker/ColorPicker';
|
||||
import { PanelOptionsGroup } from '../PanelOptionsGroup/PanelOptionsGroup';
|
||||
import { colors } from '../../utils';
|
||||
|
||||
export interface Props {
|
||||
thresholds: Threshold[];
|
||||
@ -12,50 +13,49 @@ export interface Props {
|
||||
|
||||
interface State {
|
||||
thresholds: Threshold[];
|
||||
baseColor: string;
|
||||
}
|
||||
|
||||
export class ThresholdsEditor extends PureComponent<Props, State> {
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
|
||||
this.state = { thresholds: props.thresholds, baseColor: BasicGaugeColor.Green };
|
||||
const addDefaultThreshold = this.props.thresholds.length === 0;
|
||||
const thresholds: Threshold[] = addDefaultThreshold
|
||||
? [{ index: 0, value: -Infinity, color: colors[0] }]
|
||||
: props.thresholds;
|
||||
this.state = { thresholds };
|
||||
|
||||
if (addDefaultThreshold) {
|
||||
this.onChange();
|
||||
}
|
||||
}
|
||||
|
||||
onAddThreshold = (index: number) => {
|
||||
const maxValue = 100; // hardcoded for now before we add the base threshold
|
||||
const minValue = 0; // hardcoded for now before we add the base threshold
|
||||
const { thresholds } = this.state;
|
||||
const maxValue = 100;
|
||||
const minValue = 0;
|
||||
|
||||
if (index === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newThresholds = thresholds.map(threshold => {
|
||||
if (threshold.index >= index) {
|
||||
threshold = {
|
||||
...threshold,
|
||||
index: threshold.index + 1,
|
||||
};
|
||||
const index = threshold.index + 1;
|
||||
threshold = { ...threshold, index };
|
||||
}
|
||||
|
||||
return threshold;
|
||||
});
|
||||
|
||||
// Setting value to a value between the previous thresholds
|
||||
let value;
|
||||
const beforeThreshold = newThresholds.filter(t => t.index === index - 1 && t.index !== 0)[0];
|
||||
const afterThreshold = newThresholds.filter(t => t.index === index + 1 && t.index !== 0)[0];
|
||||
const beforeThresholdValue = beforeThreshold !== undefined ? beforeThreshold.value : minValue;
|
||||
const afterThresholdValue = afterThreshold !== undefined ? afterThreshold.value : maxValue;
|
||||
const value = afterThresholdValue - (afterThresholdValue - beforeThresholdValue) / 2;
|
||||
|
||||
if (index === 0 && thresholds.length === 0) {
|
||||
value = maxValue - (maxValue - minValue) / 2;
|
||||
} else if (index === 0 && thresholds.length > 0) {
|
||||
value = newThresholds[index + 1].value - (newThresholds[index + 1].value - minValue) / 2;
|
||||
} else if (index > newThresholds[newThresholds.length - 1].index) {
|
||||
value = maxValue - (maxValue - newThresholds[index - 1].value) / 2;
|
||||
}
|
||||
|
||||
// Set a color that lies between the previous thresholds
|
||||
let color;
|
||||
if (index === 0 && thresholds.length === 0) {
|
||||
color = tinycolor.mix(BasicGaugeColor.Green, BasicGaugeColor.Red, 50).toRgbString();
|
||||
} else {
|
||||
color = tinycolor.mix(thresholds[index - 1].color as ColorInput, BasicGaugeColor.Red, 50).toRgbString();
|
||||
}
|
||||
// Set a color
|
||||
const color = colors.filter(c => newThresholds.some(t => t.color === c) === false)[0];
|
||||
|
||||
this.setState(
|
||||
{
|
||||
@ -68,23 +68,45 @@ export class ThresholdsEditor extends PureComponent<Props, State> {
|
||||
},
|
||||
]),
|
||||
},
|
||||
() => this.updateGauge()
|
||||
() => this.onChange()
|
||||
);
|
||||
};
|
||||
|
||||
onRemoveThreshold = (threshold: Threshold) => {
|
||||
if (threshold.index === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.setState(
|
||||
prevState => ({ thresholds: prevState.thresholds.filter(t => t !== threshold) }),
|
||||
() => this.updateGauge()
|
||||
prevState => {
|
||||
const newThresholds = prevState.thresholds.map(t => {
|
||||
if (t.index > threshold.index) {
|
||||
const index = t.index - 1;
|
||||
t = { ...t, index };
|
||||
}
|
||||
return t;
|
||||
});
|
||||
|
||||
return {
|
||||
thresholds: newThresholds.filter(t => t !== threshold),
|
||||
};
|
||||
},
|
||||
() => this.onChange()
|
||||
);
|
||||
};
|
||||
|
||||
onChangeThresholdValue = (event: any, threshold: Threshold) => {
|
||||
if (threshold.index === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { thresholds } = this.state;
|
||||
const parsedValue = parseInt(event.target.value, 10);
|
||||
const value = isNaN(parsedValue) ? null : parsedValue;
|
||||
|
||||
const newThresholds = thresholds.map(t => {
|
||||
if (t === threshold) {
|
||||
t = { ...t, value: event.target.value };
|
||||
if (t === threshold && t.index !== 0) {
|
||||
t = { ...t, value: value as number };
|
||||
}
|
||||
|
||||
return t;
|
||||
@ -108,18 +130,24 @@ export class ThresholdsEditor extends PureComponent<Props, State> {
|
||||
{
|
||||
thresholds: newThresholds,
|
||||
},
|
||||
() => this.updateGauge()
|
||||
() => this.onChange()
|
||||
);
|
||||
};
|
||||
|
||||
onChangeBaseColor = (color: string) => this.props.onChange(this.state.thresholds);
|
||||
onBlur = () => {
|
||||
this.setState(prevState => ({ thresholds: this.sortThresholds(prevState.thresholds) }));
|
||||
this.setState(prevState => {
|
||||
const sortThresholds = this.sortThresholds([...prevState.thresholds]);
|
||||
let index = sortThresholds.length - 1;
|
||||
sortThresholds.forEach(t => {
|
||||
t.index = index--;
|
||||
});
|
||||
return { thresholds: sortThresholds };
|
||||
});
|
||||
|
||||
this.updateGauge();
|
||||
this.onChange();
|
||||
};
|
||||
|
||||
updateGauge = () => {
|
||||
onChange = () => {
|
||||
this.props.onChange(this.state.thresholds);
|
||||
};
|
||||
|
||||
@ -129,92 +157,53 @@ export class ThresholdsEditor extends PureComponent<Props, State> {
|
||||
});
|
||||
};
|
||||
|
||||
renderThresholds() {
|
||||
const { thresholds } = this.state;
|
||||
|
||||
return thresholds.map((threshold, index) => {
|
||||
return (
|
||||
<div className="threshold-row" key={`${threshold.index}-${index}`}>
|
||||
<div className="threshold-row-inner">
|
||||
<div className="threshold-row-color">
|
||||
{threshold.color && (
|
||||
<div className="threshold-row-color-inner">
|
||||
<ColorPicker
|
||||
color={threshold.color}
|
||||
onChange={color => this.onChangeThresholdColor(threshold, color)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<input
|
||||
className="threshold-row-input"
|
||||
type="text"
|
||||
onChange={event => this.onChangeThresholdValue(event, threshold)}
|
||||
value={threshold.value}
|
||||
onBlur={this.onBlur}
|
||||
/>
|
||||
<div onClick={() => this.onRemoveThreshold(threshold)} className="threshold-row-remove">
|
||||
<i className="fa fa-times" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
renderIndicator() {
|
||||
const { thresholds } = this.state;
|
||||
|
||||
return thresholds.map((t, i) => {
|
||||
return (
|
||||
<div key={`${t.value}-${i}`} className="indicator-section">
|
||||
<div onClick={() => this.onAddThreshold(t.index + 1)} style={{ height: '50%', backgroundColor: t.color }} />
|
||||
<div onClick={() => this.onAddThreshold(t.index)} style={{ height: '50%', backgroundColor: t.color }} />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
renderBaseIndicator() {
|
||||
renderInput = (threshold: Threshold) => {
|
||||
const value = threshold.index === 0 ? 'Base' : threshold.value;
|
||||
return (
|
||||
<div className="indicator-section" style={{ height: '100%' }}>
|
||||
<div
|
||||
onClick={() => this.onAddThreshold(0)}
|
||||
style={{ height: '100%', backgroundColor: BasicGaugeColor.Green }}
|
||||
/>
|
||||
<div className="thresholds-row-input-inner">
|
||||
<span className="thresholds-row-input-inner-arrow" />
|
||||
<div className="thresholds-row-input-inner-color">
|
||||
{threshold.color && (
|
||||
<div className="thresholds-row-input-inner-color-colorpicker">
|
||||
<ColorPicker color={threshold.color} onChange={color => this.onChangeThresholdColor(threshold, color)} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="thresholds-row-input-inner-value">
|
||||
<input
|
||||
type="text"
|
||||
onChange={event => this.onChangeThresholdValue(event, threshold)}
|
||||
value={value}
|
||||
onBlur={this.onBlur}
|
||||
readOnly={threshold.index === 0}
|
||||
/>
|
||||
</div>
|
||||
{threshold.index > 0 && (
|
||||
<div className="thresholds-row-input-inner-remove" onClick={() => this.onRemoveThreshold(threshold)}>
|
||||
<i className="fa fa-times" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
renderBase() {
|
||||
const baseColor = BasicGaugeColor.Green;
|
||||
|
||||
return (
|
||||
<div className="threshold-row threshold-row-base">
|
||||
<div className="threshold-row-inner threshold-row-inner--base">
|
||||
<div className="threshold-row-color">
|
||||
<div className="threshold-row-color-inner">
|
||||
<ColorPicker color={baseColor} onChange={color => this.onChangeBaseColor(color)} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="threshold-row-label">Base</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
const { thresholds } = this.state;
|
||||
|
||||
return (
|
||||
<PanelOptionsGroup title="Thresholds">
|
||||
<div className="thresholds">
|
||||
<div className="color-indicators">
|
||||
{this.renderIndicator()}
|
||||
{this.renderBaseIndicator()}
|
||||
</div>
|
||||
<div className="threshold-rows">
|
||||
{this.renderThresholds()}
|
||||
{this.renderBase()}
|
||||
</div>
|
||||
{thresholds.map((threshold, index) => {
|
||||
return (
|
||||
<div className="thresholds-row" key={`${threshold.index}-${index}`}>
|
||||
<div className="thresholds-row-add-button" onClick={() => this.onAddThreshold(threshold.index + 1)}>
|
||||
<i className="fa fa-plus" />
|
||||
</div>
|
||||
<div className="thresholds-row-color-indicator" style={{ backgroundColor: threshold.color }} />
|
||||
<div className="thresholds-row-input">{this.renderInput(threshold)}</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</PanelOptionsGroup>
|
||||
);
|
||||
|
@ -1,46 +1,90 @@
|
||||
.thresholds {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.thresholds-row {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
height: 70px;
|
||||
}
|
||||
|
||||
.threshold-rows {
|
||||
margin-left: 5px;
|
||||
.thresholds-row:first-child > .thresholds-row-color-indicator {
|
||||
border-top-left-radius: $border-radius;
|
||||
border-top-right-radius: $border-radius;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.threshold-row {
|
||||
.thresholds-row:last-child > .thresholds-row-color-indicator {
|
||||
border-bottom-left-radius: $border-radius;
|
||||
border-bottom-right-radius: $border-radius;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.thresholds-row-add-button {
|
||||
align-self: center;
|
||||
margin-right: 5px;
|
||||
color: $green;
|
||||
height: 24px;
|
||||
width: 24px;
|
||||
background-color: $green;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-top: 3px;
|
||||
padding: 5px;
|
||||
|
||||
&::before {
|
||||
font-family: 'FontAwesome';
|
||||
content: '\f0d9';
|
||||
color: $input-label-border-color;
|
||||
}
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.threshold-row-inner {
|
||||
border: 1px solid $input-label-border-color;
|
||||
border-radius: $border-radius;
|
||||
.thresholds-row-add-button > i {
|
||||
color: $white;
|
||||
}
|
||||
|
||||
.thresholds-row-color-indicator {
|
||||
width: 10px;
|
||||
}
|
||||
|
||||
.thresholds-row-input {
|
||||
margin-top: 49px;
|
||||
margin-left: 2px;
|
||||
}
|
||||
|
||||
.thresholds-row-input-inner {
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
height: 37px;
|
||||
|
||||
&--base {
|
||||
width: auto;
|
||||
}
|
||||
justify-content: center;
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.threshold-row-color {
|
||||
width: 36px;
|
||||
border-right: 1px solid $input-label-border-color;
|
||||
.thresholds-row-input-inner > *:last-child {
|
||||
border-top-right-radius: $border-radius;
|
||||
border-bottom-right-radius: $border-radius;
|
||||
}
|
||||
|
||||
.thresholds-row-input-inner-arrow {
|
||||
align-self: center;
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-top: 6px solid transparent;
|
||||
border-bottom: 6px solid transparent;
|
||||
border-right: 6px solid $input-label-border-color;
|
||||
}
|
||||
|
||||
.thresholds-row-input-inner-value > input {
|
||||
height: $gf-form-input-height;
|
||||
padding: $input-padding-y $input-padding-x;
|
||||
width: 150px;
|
||||
border-top: 1px solid $input-label-border-color;
|
||||
border-bottom: 1px solid $input-label-border-color;
|
||||
}
|
||||
|
||||
.thresholds-row-input-inner-color {
|
||||
width: 42px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: $input-bg;
|
||||
border: 1px solid $input-label-border-color;
|
||||
}
|
||||
|
||||
.threshold-row-color-inner {
|
||||
.thresholds-row-input-inner-color-colorpicker {
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
@ -48,56 +92,14 @@
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
|
||||
.threshold-row-input {
|
||||
padding: 8px 10px;
|
||||
width: 150px;
|
||||
}
|
||||
|
||||
.threshold-row-label {
|
||||
.thresholds-row-input-inner-remove {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: $gf-form-input-height;
|
||||
padding: $input-padding-y $input-padding-x;
|
||||
width: 42px;
|
||||
background-color: $input-label-bg;
|
||||
padding: 5px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.threshold-row-add-label {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
padding: 5px 8px;
|
||||
}
|
||||
|
||||
.threshold-row-remove {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 37px;
|
||||
width: 37px;
|
||||
border: 1px solid $input-label-border-color;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.threshold-row-add {
|
||||
border-right: $border-width solid $input-label-border-color;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
background-color: $green;
|
||||
}
|
||||
|
||||
.threshold-row-label {
|
||||
border-top-left-radius: 0;
|
||||
border-bottom-left-radius: 0;
|
||||
}
|
||||
|
||||
.indicator-section {
|
||||
width: 100%;
|
||||
height: 50px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.color-indicators {
|
||||
width: 15px;
|
||||
border-bottom-left-radius: $border-radius;
|
||||
border-bottom-right-radius: $border-radius;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
@ -1,22 +1,22 @@
|
||||
import React, { PureComponent } from 'react';
|
||||
import { MappingType, RangeMap, Select, ValueMap } from '@grafana/ui';
|
||||
import React, { ChangeEvent, PureComponent } from 'react';
|
||||
|
||||
import { Label } from 'app/core/components/Label/Label';
|
||||
import { MappingType, ValueMapping } from '../../types';
|
||||
import { FormField, FormLabel, Select } from '..';
|
||||
|
||||
interface Props {
|
||||
mapping: ValueMap | RangeMap;
|
||||
updateMapping: (mapping) => void;
|
||||
removeMapping: () => void;
|
||||
export interface Props {
|
||||
valueMapping: ValueMapping;
|
||||
updateValueMapping: (valueMapping: ValueMapping) => void;
|
||||
removeValueMapping: () => void;
|
||||
}
|
||||
|
||||
interface State {
|
||||
from: string;
|
||||
from?: string;
|
||||
id: number;
|
||||
operator: string;
|
||||
text: string;
|
||||
to: string;
|
||||
to?: string;
|
||||
type: MappingType;
|
||||
value: string;
|
||||
value?: string;
|
||||
}
|
||||
|
||||
const mappingOptions = [
|
||||
@ -25,36 +25,34 @@ const mappingOptions = [
|
||||
];
|
||||
|
||||
export default class MappingRow extends PureComponent<Props, State> {
|
||||
constructor(props) {
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
...props.mapping,
|
||||
};
|
||||
this.state = { ...props.valueMapping };
|
||||
}
|
||||
|
||||
onMappingValueChange = event => {
|
||||
onMappingValueChange = (event: ChangeEvent<HTMLInputElement>) => {
|
||||
this.setState({ value: event.target.value });
|
||||
};
|
||||
|
||||
onMappingFromChange = event => {
|
||||
onMappingFromChange = (event: ChangeEvent<HTMLInputElement>) => {
|
||||
this.setState({ from: event.target.value });
|
||||
};
|
||||
|
||||
onMappingToChange = event => {
|
||||
onMappingToChange = (event: ChangeEvent<HTMLInputElement>) => {
|
||||
this.setState({ to: event.target.value });
|
||||
};
|
||||
|
||||
onMappingTextChange = event => {
|
||||
onMappingTextChange = (event: ChangeEvent<HTMLInputElement>) => {
|
||||
this.setState({ text: event.target.value });
|
||||
};
|
||||
|
||||
onMappingTypeChange = mappingType => {
|
||||
onMappingTypeChange = (mappingType: MappingType) => {
|
||||
this.setState({ type: mappingType });
|
||||
};
|
||||
|
||||
updateMapping = () => {
|
||||
this.props.updateMapping({ ...this.state });
|
||||
this.props.updateValueMapping({ ...this.state } as ValueMapping);
|
||||
};
|
||||
|
||||
renderRow() {
|
||||
@ -63,30 +61,28 @@ export default class MappingRow extends PureComponent<Props, State> {
|
||||
if (type === MappingType.RangeToText) {
|
||||
return (
|
||||
<>
|
||||
<div className="gf-form">
|
||||
<Label width={4}>From</Label>
|
||||
<FormField
|
||||
label="From"
|
||||
labelWidth={4}
|
||||
inputWidth={8}
|
||||
onBlur={this.updateMapping}
|
||||
onChange={this.onMappingFromChange}
|
||||
value={from}
|
||||
/>
|
||||
<FormField
|
||||
label="To"
|
||||
labelWidth={4}
|
||||
inputWidth={8}
|
||||
onBlur={this.updateMapping}
|
||||
onChange={this.onMappingToChange}
|
||||
value={to}
|
||||
/>
|
||||
<div className="gf-form gf-form--grow">
|
||||
<FormLabel width={4}>Text</FormLabel>
|
||||
<input
|
||||
className="gf-form-input width-8"
|
||||
value={from}
|
||||
className="gf-form-input"
|
||||
onBlur={this.updateMapping}
|
||||
onChange={this.onMappingFromChange}
|
||||
/>
|
||||
</div>
|
||||
<div className="gf-form">
|
||||
<Label width={4}>To</Label>
|
||||
<input
|
||||
className="gf-form-input width-8"
|
||||
value={to}
|
||||
onBlur={this.updateMapping}
|
||||
onChange={this.onMappingToChange}
|
||||
/>
|
||||
</div>
|
||||
<div className="gf-form">
|
||||
<Label width={4}>Text</Label>
|
||||
<input
|
||||
className="gf-form-input width-10"
|
||||
value={text}
|
||||
onBlur={this.updateMapping}
|
||||
onChange={this.onMappingTextChange}
|
||||
/>
|
||||
</div>
|
||||
@ -96,17 +92,16 @@ export default class MappingRow extends PureComponent<Props, State> {
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="gf-form">
|
||||
<Label width={4}>Value</Label>
|
||||
<input
|
||||
className="gf-form-input width-8"
|
||||
onBlur={this.updateMapping}
|
||||
onChange={this.onMappingValueChange}
|
||||
value={value}
|
||||
/>
|
||||
</div>
|
||||
<FormField
|
||||
label="Value"
|
||||
labelWidth={4}
|
||||
onBlur={this.updateMapping}
|
||||
onChange={this.onMappingValueChange}
|
||||
value={value}
|
||||
inputWidth={8}
|
||||
/>
|
||||
<div className="gf-form gf-form--grow">
|
||||
<Label width={4}>Text</Label>
|
||||
<FormLabel width={4}>Text</FormLabel>
|
||||
<input
|
||||
className="gf-form-input"
|
||||
onBlur={this.updateMapping}
|
||||
@ -124,7 +119,7 @@ export default class MappingRow extends PureComponent<Props, State> {
|
||||
return (
|
||||
<div className="gf-form-inline">
|
||||
<div className="gf-form">
|
||||
<Label width={5}>Type</Label>
|
||||
<FormLabel width={5}>Type</FormLabel>
|
||||
<Select
|
||||
placeholder="Choose type"
|
||||
isSearchable={false}
|
||||
@ -136,7 +131,7 @@ export default class MappingRow extends PureComponent<Props, State> {
|
||||
</div>
|
||||
{this.renderRow()}
|
||||
<div className="gf-form">
|
||||
<button onClick={this.props.removeMapping} className="gf-form-label gf-form-label--btn">
|
||||
<button onClick={this.props.removeValueMapping} className="gf-form-label gf-form-label--btn">
|
||||
<i className="fa fa-times" />
|
||||
</button>
|
||||
</div>
|
@ -1,27 +1,23 @@
|
||||
import React from 'react';
|
||||
import { shallow } from 'enzyme';
|
||||
import { GaugeOptions, MappingType, PanelOptionsProps } from '@grafana/ui';
|
||||
import { defaultProps } from 'app/plugins/panel/gauge/GaugePanelOptions';
|
||||
|
||||
import ValueMappings from './ValueMappings';
|
||||
import { ValueMappingsEditor, Props } from './ValueMappingsEditor';
|
||||
import { MappingType } from '../../types/panel';
|
||||
|
||||
const setup = (propOverrides?: object) => {
|
||||
const props: PanelOptionsProps<GaugeOptions> = {
|
||||
const props: Props = {
|
||||
onChange: jest.fn(),
|
||||
options: {
|
||||
...defaultProps.options,
|
||||
mappings: [
|
||||
{ id: 1, operator: '', type: MappingType.ValueToText, value: '20', text: 'Ok' },
|
||||
{ id: 2, operator: '', type: MappingType.RangeToText, from: '21', to: '30', text: 'Meh' },
|
||||
],
|
||||
},
|
||||
valueMappings: [
|
||||
{ id: 1, operator: '', type: MappingType.ValueToText, value: '20', text: 'Ok' },
|
||||
{ id: 2, operator: '', type: MappingType.RangeToText, from: '21', to: '30', text: 'Meh' },
|
||||
],
|
||||
};
|
||||
|
||||
Object.assign(props, propOverrides);
|
||||
|
||||
const wrapper = shallow(<ValueMappings {...props} />);
|
||||
const wrapper = shallow(<ValueMappingsEditor {...props} />);
|
||||
|
||||
const instance = wrapper.instance() as ValueMappings;
|
||||
const instance = wrapper.instance() as ValueMappingsEditor;
|
||||
|
||||
return {
|
||||
instance,
|
||||
@ -40,18 +36,20 @@ describe('Render', () => {
|
||||
describe('On remove mapping', () => {
|
||||
it('Should remove mapping with id 0', () => {
|
||||
const { instance } = setup();
|
||||
|
||||
instance.onRemoveMapping(1);
|
||||
|
||||
expect(instance.state.mappings).toEqual([
|
||||
expect(instance.state.valueMappings).toEqual([
|
||||
{ id: 2, operator: '', type: MappingType.RangeToText, from: '21', to: '30', text: 'Meh' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should remove mapping with id 1', () => {
|
||||
const { instance } = setup();
|
||||
|
||||
instance.onRemoveMapping(2);
|
||||
|
||||
expect(instance.state.mappings).toEqual([
|
||||
expect(instance.state.valueMappings).toEqual([
|
||||
{ id: 1, operator: '', type: MappingType.ValueToText, value: '20', text: 'Ok' },
|
||||
]);
|
||||
});
|
||||
@ -67,7 +65,7 @@ describe('Next id to add', () => {
|
||||
});
|
||||
|
||||
it('should default to 1', () => {
|
||||
const { instance } = setup({ options: { ...defaultProps.options } });
|
||||
const { instance } = setup({ valueMappings: [] });
|
||||
|
||||
expect(instance.state.nextIdToAdd).toEqual(1);
|
||||
});
|
@ -1,33 +1,39 @@
|
||||
import React, { PureComponent } from 'react';
|
||||
import { GaugeOptions, PanelOptionsProps, MappingType, RangeMap, ValueMap, PanelOptionsGroup } from '@grafana/ui';
|
||||
|
||||
import MappingRow from './MappingRow';
|
||||
import { MappingType, ValueMapping } from '../../types/panel';
|
||||
import { PanelOptionsGroup } from '../PanelOptionsGroup/PanelOptionsGroup';
|
||||
|
||||
export interface Props {
|
||||
valueMappings: ValueMapping[];
|
||||
onChange: (valueMappings: ValueMapping[]) => void;
|
||||
}
|
||||
|
||||
interface State {
|
||||
mappings: Array<ValueMap | RangeMap>;
|
||||
valueMappings: ValueMapping[];
|
||||
nextIdToAdd: number;
|
||||
}
|
||||
|
||||
export default class ValueMappings extends PureComponent<PanelOptionsProps<GaugeOptions>, State> {
|
||||
constructor(props) {
|
||||
export class ValueMappingsEditor extends PureComponent<Props, State> {
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
|
||||
const mappings = props.options.mappings;
|
||||
const mappings = props.valueMappings;
|
||||
|
||||
this.state = {
|
||||
mappings: mappings || [],
|
||||
nextIdToAdd: mappings.length > 0 ? this.getMaxIdFromMappings(mappings) : 1,
|
||||
valueMappings: mappings,
|
||||
nextIdToAdd: mappings.length > 0 ? this.getMaxIdFromValueMappings(mappings) : 1,
|
||||
};
|
||||
}
|
||||
|
||||
getMaxIdFromMappings(mappings) {
|
||||
getMaxIdFromValueMappings(mappings: ValueMapping[]) {
|
||||
return Math.max.apply(null, mappings.map(mapping => mapping.id).map(m => m)) + 1;
|
||||
}
|
||||
|
||||
addMapping = () =>
|
||||
this.setState(prevState => ({
|
||||
mappings: [
|
||||
...prevState.mappings,
|
||||
valueMappings: [
|
||||
...prevState.valueMappings,
|
||||
{
|
||||
id: prevState.nextIdToAdd,
|
||||
operator: '',
|
||||
@ -41,23 +47,23 @@ export default class ValueMappings extends PureComponent<PanelOptionsProps<Gauge
|
||||
nextIdToAdd: prevState.nextIdToAdd + 1,
|
||||
}));
|
||||
|
||||
onRemoveMapping = id => {
|
||||
onRemoveMapping = (id: number) => {
|
||||
this.setState(
|
||||
prevState => ({
|
||||
mappings: prevState.mappings.filter(m => {
|
||||
valueMappings: prevState.valueMappings.filter(m => {
|
||||
return m.id !== id;
|
||||
}),
|
||||
}),
|
||||
() => {
|
||||
this.props.onChange({ ...this.props.options, mappings: this.state.mappings });
|
||||
this.props.onChange(this.state.valueMappings);
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
updateGauge = mapping => {
|
||||
updateGauge = (mapping: ValueMapping) => {
|
||||
this.setState(
|
||||
prevState => ({
|
||||
mappings: prevState.mappings.map(m => {
|
||||
valueMappings: prevState.valueMappings.map(m => {
|
||||
if (m.id === mapping.id) {
|
||||
return { ...mapping };
|
||||
}
|
||||
@ -66,24 +72,24 @@ export default class ValueMappings extends PureComponent<PanelOptionsProps<Gauge
|
||||
}),
|
||||
}),
|
||||
() => {
|
||||
this.props.onChange({ ...this.props.options, mappings: this.state.mappings });
|
||||
this.props.onChange(this.state.valueMappings);
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
render() {
|
||||
const { mappings } = this.state;
|
||||
const { valueMappings } = this.state;
|
||||
|
||||
return (
|
||||
<PanelOptionsGroup title="Value Mappings">
|
||||
<div>
|
||||
{mappings.length > 0 &&
|
||||
mappings.map((mapping, index) => (
|
||||
{valueMappings.length > 0 &&
|
||||
valueMappings.map((valueMapping, index) => (
|
||||
<MappingRow
|
||||
key={`${mapping.text}-${index}`}
|
||||
mapping={mapping}
|
||||
updateMapping={this.updateGauge}
|
||||
removeMapping={() => this.onRemoveMapping(mapping.id)}
|
||||
key={`${valueMapping.text}-${index}`}
|
||||
valueMapping={valueMapping}
|
||||
updateValueMapping={this.updateGauge}
|
||||
removeValueMapping={() => this.onRemoveMapping(valueMapping.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
@ -7,7 +7,9 @@ exports[`Render should render component 1`] = `
|
||||
<div>
|
||||
<MappingRow
|
||||
key="Ok-0"
|
||||
mapping={
|
||||
removeValueMapping={[Function]}
|
||||
updateValueMapping={[Function]}
|
||||
valueMapping={
|
||||
Object {
|
||||
"id": 1,
|
||||
"operator": "",
|
||||
@ -16,12 +18,12 @@ exports[`Render should render component 1`] = `
|
||||
"value": "20",
|
||||
}
|
||||
}
|
||||
removeMapping={[Function]}
|
||||
updateMapping={[Function]}
|
||||
/>
|
||||
<MappingRow
|
||||
key="Meh-1"
|
||||
mapping={
|
||||
removeValueMapping={[Function]}
|
||||
updateValueMapping={[Function]}
|
||||
valueMapping={
|
||||
Object {
|
||||
"from": "21",
|
||||
"id": 2,
|
||||
@ -31,8 +33,6 @@ exports[`Render should render component 1`] = `
|
||||
"type": 2,
|
||||
}
|
||||
}
|
||||
removeMapping={[Function]}
|
||||
updateMapping={[Function]}
|
||||
/>
|
||||
</div>
|
||||
<div
|
@ -5,3 +5,6 @@
|
||||
@import 'Select/Select';
|
||||
@import 'PanelOptionsGroup/PanelOptionsGroup';
|
||||
@import 'PanelOptionsGrid/PanelOptionsGrid';
|
||||
@import 'ColorPicker/ColorPicker';
|
||||
@import 'ValueMappingsEditor/ValueMappingsEditor';
|
||||
@import "FormField/FormField";
|
||||
|
@ -9,12 +9,17 @@ export { IndicatorsContainer } from './Select/IndicatorsContainer';
|
||||
export { NoOptionsMessage } from './Select/NoOptionsMessage';
|
||||
export { default as resetSelectStyles } from './Select/resetSelectStyles';
|
||||
|
||||
// Forms
|
||||
export { FormLabel } from './FormLabel/FormLabel';
|
||||
export { FormField } from './FormField/FormField';
|
||||
|
||||
export { LoadingPlaceholder } from './LoadingPlaceholder/LoadingPlaceholder';
|
||||
export { ColorPicker } from './ColorPicker/ColorPicker';
|
||||
export { SeriesColorPickerPopover } from './ColorPicker/SeriesColorPickerPopover';
|
||||
export { SeriesColorPicker } from './ColorPicker/SeriesColorPicker';
|
||||
export { ThresholdsEditor } from './ThresholdsEditor/ThresholdsEditor';
|
||||
export { GfFormLabel } from './GfFormLabel/GfFormLabel';
|
||||
export { Graph } from './Graph/Graph';
|
||||
export { PanelOptionsGroup } from './PanelOptionsGroup/PanelOptionsGroup';
|
||||
export { PanelOptionsGrid } from './PanelOptionsGrid/PanelOptionsGrid';
|
||||
export { ValueMappingsEditor } from './ValueMappingsEditor/ValueMappingsEditor';
|
||||
export { Gauge } from './Gauge/Gauge';
|
||||
|
@ -1 +1,3 @@
|
||||
@import 'vendor/spectrum';
|
||||
@import 'components/index';
|
||||
|
||||
|
89
packages/grafana-ui/src/types/datasource.ts
Normal file
89
packages/grafana-ui/src/types/datasource.ts
Normal file
@ -0,0 +1,89 @@
|
||||
import { TimeRange, RawTimeRange } from './time';
|
||||
import { TimeSeries } from './series';
|
||||
import { PluginMeta } from './plugin';
|
||||
|
||||
export interface DataQueryResponse {
|
||||
data: TimeSeries[];
|
||||
}
|
||||
|
||||
export interface DataQuery {
|
||||
/**
|
||||
* A - Z
|
||||
*/
|
||||
refId: string;
|
||||
|
||||
/**
|
||||
* true if query is disabled (ie not executed / sent to TSDB)
|
||||
*/
|
||||
hide?: boolean;
|
||||
|
||||
/**
|
||||
* Unique, guid like, string used in explore mode
|
||||
*/
|
||||
key?: string;
|
||||
|
||||
/**
|
||||
* For mixed data sources the selected datasource is on the query level.
|
||||
* For non mixed scenarios this is undefined.
|
||||
*/
|
||||
datasource?: string | null;
|
||||
}
|
||||
|
||||
export interface DataQueryOptions<TQuery extends DataQuery = DataQuery> {
|
||||
timezone: string;
|
||||
range: TimeRange;
|
||||
rangeRaw: RawTimeRange;
|
||||
targets: TQuery[];
|
||||
panelId: number;
|
||||
dashboardId: number;
|
||||
cacheTimeout?: string;
|
||||
interval: string;
|
||||
intervalMs: number;
|
||||
maxDataPoints: number;
|
||||
scopedVars: object;
|
||||
}
|
||||
|
||||
export interface QueryFix {
|
||||
type: string;
|
||||
label: string;
|
||||
action?: QueryFixAction;
|
||||
}
|
||||
|
||||
export interface QueryFixAction {
|
||||
type: string;
|
||||
query?: string;
|
||||
preventSubmit?: boolean;
|
||||
}
|
||||
|
||||
export interface QueryHint {
|
||||
type: string;
|
||||
label: string;
|
||||
fix?: QueryFix;
|
||||
}
|
||||
|
||||
export interface DataSourceSettings {
|
||||
id: number;
|
||||
orgId: number;
|
||||
name: string;
|
||||
typeLogoUrl: string;
|
||||
type: string;
|
||||
access: string;
|
||||
url: string;
|
||||
password: string;
|
||||
user: string;
|
||||
database: string;
|
||||
basicAuth: boolean;
|
||||
basicAuthPassword: string;
|
||||
basicAuthUser: string;
|
||||
isDefault: boolean;
|
||||
jsonData: { authType: string; defaultRegion: string };
|
||||
readOnly: boolean;
|
||||
withCredentials: boolean;
|
||||
}
|
||||
|
||||
export interface DataSourceSelectItem {
|
||||
name: string;
|
||||
value: string | null;
|
||||
meta: PluginMeta;
|
||||
sort: string;
|
||||
}
|
@ -1,16 +0,0 @@
|
||||
import { RangeMap, Threshold, ValueMap } from './panel';
|
||||
|
||||
export interface GaugeOptions {
|
||||
baseColor: string;
|
||||
decimals: number;
|
||||
mappings: Array<RangeMap | ValueMap>;
|
||||
maxValue: number;
|
||||
minValue: number;
|
||||
prefix: string;
|
||||
showThresholdLabels: boolean;
|
||||
showThresholdMarkers: boolean;
|
||||
stat: string;
|
||||
suffix: string;
|
||||
thresholds: Threshold[];
|
||||
unit: string;
|
||||
}
|
@ -1,4 +1,5 @@
|
||||
export * from './series';
|
||||
export * from './time';
|
||||
export * from './panel';
|
||||
export * from './gauge';
|
||||
export * from './plugin';
|
||||
export * from './datasource';
|
||||
|
@ -1,6 +1,8 @@
|
||||
import { TimeSeries, LoadingState } from './series';
|
||||
import { TimeRange } from './time';
|
||||
|
||||
export type InterpolateFunction = (value: string, format?: string | Function) => string;
|
||||
|
||||
export interface PanelProps<T = any> {
|
||||
timeSeries: TimeSeries[];
|
||||
timeRange: TimeRange;
|
||||
@ -9,6 +11,7 @@ export interface PanelProps<T = any> {
|
||||
renderCounter: number;
|
||||
width: number;
|
||||
height: number;
|
||||
onInterpolate: InterpolateFunction;
|
||||
}
|
||||
|
||||
export interface PanelOptionsProps<T = any> {
|
||||
@ -53,6 +56,8 @@ interface BaseMap {
|
||||
type: MappingType;
|
||||
}
|
||||
|
||||
export type ValueMapping = ValueMap | RangeMap;
|
||||
|
||||
export interface ValueMap extends BaseMap {
|
||||
value: string;
|
||||
}
|
||||
@ -61,3 +66,10 @@ export interface RangeMap extends BaseMap {
|
||||
from: string;
|
||||
to: string;
|
||||
}
|
||||
|
||||
export type ThemeName = 'dark' | 'light';
|
||||
|
||||
export enum ThemeNames {
|
||||
Dark = 'dark',
|
||||
Light = 'light',
|
||||
}
|
||||
|
118
packages/grafana-ui/src/types/plugin.ts
Normal file
118
packages/grafana-ui/src/types/plugin.ts
Normal file
@ -0,0 +1,118 @@
|
||||
import { ComponentClass } from 'react';
|
||||
import { PanelProps, PanelOptionsProps } from './panel';
|
||||
import { DataQueryOptions, DataQuery, DataQueryResponse, QueryHint } from './datasource';
|
||||
|
||||
export interface DataSourceApi<TQuery extends DataQuery = DataQuery> {
|
||||
/**
|
||||
* min interval range
|
||||
*/
|
||||
interval?: string;
|
||||
|
||||
/**
|
||||
* Imports queries from a different datasource
|
||||
*/
|
||||
importQueries?(queries: TQuery[], originMeta: PluginMeta): Promise<TQuery[]>;
|
||||
|
||||
/**
|
||||
* Initializes a datasource after instantiation
|
||||
*/
|
||||
init?: () => void;
|
||||
|
||||
/**
|
||||
* Main metrics / data query action
|
||||
*/
|
||||
query(options: DataQueryOptions<TQuery>): Promise<DataQueryResponse>;
|
||||
|
||||
/**
|
||||
* Test & verify datasource settings & connection details
|
||||
*/
|
||||
testDatasource(): Promise<any>;
|
||||
|
||||
/**
|
||||
* Get hints for query improvements
|
||||
*/
|
||||
getQueryHints?(query: TQuery, results: any[], ...rest: any): QueryHint[];
|
||||
|
||||
/**
|
||||
* Set after constructor is called by Grafana
|
||||
*/
|
||||
name?: string;
|
||||
meta?: PluginMeta;
|
||||
pluginExports?: PluginExports;
|
||||
}
|
||||
|
||||
export interface QueryEditorProps<DSType extends DataSourceApi, TQuery extends DataQuery> {
|
||||
datasource: DSType;
|
||||
query: TQuery;
|
||||
onExecuteQuery?: () => void;
|
||||
onQueryChange?: (value: TQuery) => void;
|
||||
}
|
||||
|
||||
export interface PluginExports {
|
||||
Datasource?: DataSourceApi;
|
||||
QueryCtrl?: any;
|
||||
QueryEditor?: ComponentClass<QueryEditorProps<DataSourceApi,DataQuery>>;
|
||||
ConfigCtrl?: any;
|
||||
AnnotationsQueryCtrl?: any;
|
||||
VariableQueryEditor?: any;
|
||||
ExploreQueryField?: any;
|
||||
ExploreStartPage?: any;
|
||||
|
||||
// Panel plugin
|
||||
PanelCtrl?: any;
|
||||
Panel?: ComponentClass<PanelProps>;
|
||||
PanelOptions?: ComponentClass<PanelOptionsProps>;
|
||||
PanelDefaults?: any;
|
||||
}
|
||||
|
||||
export interface PluginMeta {
|
||||
id: string;
|
||||
name: string;
|
||||
info: PluginMetaInfo;
|
||||
includes: PluginInclude[];
|
||||
|
||||
// Datasource-specific
|
||||
metrics?: boolean;
|
||||
tables?: boolean;
|
||||
logs?: boolean;
|
||||
explore?: boolean;
|
||||
annotations?: boolean;
|
||||
mixed?: boolean;
|
||||
hasQueryHelp?: boolean;
|
||||
queryOptions?: PluginMetaQueryOptions;
|
||||
}
|
||||
|
||||
interface PluginMetaQueryOptions {
|
||||
cacheTimeout?: boolean;
|
||||
maxDataPoints?: boolean;
|
||||
minInterval?: boolean;
|
||||
}
|
||||
|
||||
export interface PluginInclude {
|
||||
type: string;
|
||||
name: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
interface PluginMetaInfoLink {
|
||||
name: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface PluginMetaInfo {
|
||||
author: {
|
||||
name: string;
|
||||
url?: string;
|
||||
};
|
||||
description: string;
|
||||
links: PluginMetaInfoLink[];
|
||||
logos: {
|
||||
large: string;
|
||||
small: string;
|
||||
};
|
||||
screenshots: any[];
|
||||
updated: string;
|
||||
version: string;
|
||||
}
|
||||
|
||||
|
@ -21,9 +21,12 @@ export interface TimeSeriesVM {
|
||||
color: string;
|
||||
data: TimeSeriesValue[][];
|
||||
stats: TimeSeriesStats;
|
||||
allIsNull: boolean;
|
||||
allIsZero: boolean;
|
||||
}
|
||||
|
||||
export interface TimeSeriesStats {
|
||||
[key: string]: number | null;
|
||||
total: number | null;
|
||||
max: number | null;
|
||||
min: number | null;
|
||||
@ -36,8 +39,6 @@ export interface TimeSeriesStats {
|
||||
range: number | null;
|
||||
timeStep: number;
|
||||
count: number;
|
||||
allIsNull: boolean;
|
||||
allIsZero: boolean;
|
||||
}
|
||||
|
||||
export enum NullValueMode {
|
||||
|
@ -1,18 +1,19 @@
|
||||
// Libraries
|
||||
import _ from 'lodash';
|
||||
|
||||
import { colors } from './colors';
|
||||
|
||||
// Types
|
||||
import { TimeSeries, TimeSeriesVMs, NullValueMode, TimeSeriesValue } from '../types';
|
||||
|
||||
interface Options {
|
||||
timeSeries: TimeSeries[];
|
||||
nullValueMode: NullValueMode;
|
||||
colorPalette: string[];
|
||||
}
|
||||
|
||||
export function processTimeSeries({ timeSeries, nullValueMode, colorPalette }: Options): TimeSeriesVMs {
|
||||
export function processTimeSeries({ timeSeries, nullValueMode }: Options): TimeSeriesVMs {
|
||||
const vmSeries = timeSeries.map((item, index) => {
|
||||
const colorIndex = index % colorPalette.length;
|
||||
const colorIndex = index % colors.length;
|
||||
const label = item.target;
|
||||
const result = [];
|
||||
|
||||
@ -49,8 +50,8 @@ export function processTimeSeries({ timeSeries, nullValueMode, colorPalette }: O
|
||||
continue;
|
||||
}
|
||||
|
||||
if (typeof currentValue !== 'number') {
|
||||
continue;
|
||||
if (currentValue !== null && typeof currentValue !== 'number') {
|
||||
throw {message: 'Time series contains non number values'};
|
||||
}
|
||||
|
||||
// Due to missing values we could have different timeStep all along the series
|
||||
@ -150,7 +151,9 @@ export function processTimeSeries({ timeSeries, nullValueMode, colorPalette }: O
|
||||
return {
|
||||
data: result,
|
||||
label: label,
|
||||
color: colorPalette[colorIndex],
|
||||
color: colors[colorIndex],
|
||||
allIsZero,
|
||||
allIsNull,
|
||||
stats: {
|
||||
total,
|
||||
min,
|
||||
@ -164,8 +167,6 @@ export function processTimeSeries({ timeSeries, nullValueMode, colorPalette }: O
|
||||
range,
|
||||
count,
|
||||
first,
|
||||
allIsZero,
|
||||
allIsNull,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
81
packages/grafana-ui/src/utils/valueMappings.test.ts
Normal file
81
packages/grafana-ui/src/utils/valueMappings.test.ts
Normal file
@ -0,0 +1,81 @@
|
||||
import { getMappedValue } from './valueMappings';
|
||||
import { ValueMapping, MappingType } from '../types/panel';
|
||||
|
||||
describe('Format value with value mappings', () => {
|
||||
it('should return undefined with no valuemappings', () => {
|
||||
const valueMappings: ValueMapping[] = [];
|
||||
const value = '10';
|
||||
|
||||
expect(getMappedValue(valueMappings, value)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return undefined with no matching valuemappings', () => {
|
||||
const valueMappings: ValueMapping[] = [
|
||||
{ id: 0, operator: '', text: 'elva', type: MappingType.ValueToText, value: '11' },
|
||||
{ id: 1, operator: '', text: '1-9', type: MappingType.RangeToText, from: '1', to: '9' },
|
||||
];
|
||||
const value = '10';
|
||||
|
||||
expect(getMappedValue(valueMappings, value)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return first matching mapping with lowest id', () => {
|
||||
const valueMappings: ValueMapping[] = [
|
||||
{ id: 0, operator: '', text: '1-20', type: MappingType.RangeToText, from: '1', to: '20' },
|
||||
{ id: 1, operator: '', text: 'tio', type: MappingType.ValueToText, value: '10' },
|
||||
];
|
||||
const value = '10';
|
||||
|
||||
expect(getMappedValue(valueMappings, value).text).toEqual('1-20');
|
||||
});
|
||||
|
||||
it('should return if value is null and value to text mapping value is null', () => {
|
||||
const valueMappings: ValueMapping[] = [
|
||||
{ id: 0, operator: '', text: '1-20', type: MappingType.RangeToText, from: '1', to: '20' },
|
||||
{ id: 1, operator: '', text: '<NULL>', type: MappingType.ValueToText, value: 'null' },
|
||||
];
|
||||
const value = null;
|
||||
|
||||
expect(getMappedValue(valueMappings, value).text).toEqual('<NULL>');
|
||||
});
|
||||
|
||||
it('should return if value is null and range to text mapping from and to is null', () => {
|
||||
const valueMappings: ValueMapping[] = [
|
||||
{ id: 0, operator: '', text: '<NULL>', type: MappingType.RangeToText, from: 'null', to: 'null' },
|
||||
{ id: 1, operator: '', text: 'elva', type: MappingType.ValueToText, value: '11' },
|
||||
];
|
||||
const value = null;
|
||||
|
||||
expect(getMappedValue(valueMappings, value).text).toEqual('<NULL>');
|
||||
});
|
||||
|
||||
it('should return rangeToText mapping where value equals to', () => {
|
||||
const valueMappings: ValueMapping[] = [
|
||||
{ id: 0, operator: '', text: '1-10', type: MappingType.RangeToText, from: '1', to: '10' },
|
||||
{ id: 1, operator: '', text: 'elva', type: MappingType.ValueToText, value: '11' },
|
||||
];
|
||||
const value = '10';
|
||||
|
||||
expect(getMappedValue(valueMappings, value).text).toEqual('1-10');
|
||||
});
|
||||
|
||||
it('should return rangeToText mapping where value equals from', () => {
|
||||
const valueMappings: ValueMapping[] = [
|
||||
{ id: 0, operator: '', text: '10-20', type: MappingType.RangeToText, from: '10', to: '20' },
|
||||
{ id: 1, operator: '', text: 'elva', type: MappingType.ValueToText, value: '11' },
|
||||
];
|
||||
const value = '10';
|
||||
|
||||
expect(getMappedValue(valueMappings, value).text).toEqual('10-20');
|
||||
});
|
||||
|
||||
it('should return rangeToText mapping where value is between from and to', () => {
|
||||
const valueMappings: ValueMapping[] = [
|
||||
{ id: 0, operator: '', text: '1-20', type: MappingType.RangeToText, from: '1', to: '20' },
|
||||
{ id: 1, operator: '', text: 'elva', type: MappingType.ValueToText, value: '11' },
|
||||
];
|
||||
const value = '10';
|
||||
|
||||
expect(getMappedValue(valueMappings, value).text).toEqual('1-20');
|
||||
});
|
||||
});
|
89
packages/grafana-ui/src/utils/valueMappings.ts
Normal file
89
packages/grafana-ui/src/utils/valueMappings.ts
Normal file
@ -0,0 +1,89 @@
|
||||
import { ValueMapping, MappingType, ValueMap, RangeMap } from '../types';
|
||||
|
||||
export type TimeSeriesValue = string | number | null;
|
||||
|
||||
const addValueToTextMappingText = (
|
||||
allValueMappings: ValueMapping[],
|
||||
valueToTextMapping: ValueMap,
|
||||
value: TimeSeriesValue
|
||||
) => {
|
||||
if (valueToTextMapping.value === undefined) {
|
||||
return allValueMappings;
|
||||
}
|
||||
|
||||
if (value === null && valueToTextMapping.value && valueToTextMapping.value.toLowerCase() === 'null') {
|
||||
return allValueMappings.concat(valueToTextMapping);
|
||||
}
|
||||
|
||||
const valueAsNumber = parseFloat(value as string);
|
||||
const valueToTextMappingAsNumber = parseFloat(valueToTextMapping.value as string);
|
||||
|
||||
if (isNaN(valueAsNumber) || isNaN(valueToTextMappingAsNumber)) {
|
||||
return allValueMappings;
|
||||
}
|
||||
|
||||
if (valueAsNumber !== valueToTextMappingAsNumber) {
|
||||
return allValueMappings;
|
||||
}
|
||||
|
||||
return allValueMappings.concat(valueToTextMapping);
|
||||
};
|
||||
|
||||
const addRangeToTextMappingText = (
|
||||
allValueMappings: ValueMapping[],
|
||||
rangeToTextMapping: RangeMap,
|
||||
value: TimeSeriesValue
|
||||
) => {
|
||||
if (rangeToTextMapping.from === undefined || rangeToTextMapping.to === undefined || value === undefined) {
|
||||
return allValueMappings;
|
||||
}
|
||||
|
||||
if (
|
||||
value === null &&
|
||||
rangeToTextMapping.from &&
|
||||
rangeToTextMapping.to &&
|
||||
rangeToTextMapping.from.toLowerCase() === 'null' &&
|
||||
rangeToTextMapping.to.toLowerCase() === 'null'
|
||||
) {
|
||||
return allValueMappings.concat(rangeToTextMapping);
|
||||
}
|
||||
|
||||
const valueAsNumber = parseFloat(value as string);
|
||||
const fromAsNumber = parseFloat(rangeToTextMapping.from as string);
|
||||
const toAsNumber = parseFloat(rangeToTextMapping.to as string);
|
||||
|
||||
if (isNaN(valueAsNumber) || isNaN(fromAsNumber) || isNaN(toAsNumber)) {
|
||||
return allValueMappings;
|
||||
}
|
||||
|
||||
if (valueAsNumber >= fromAsNumber && valueAsNumber <= toAsNumber) {
|
||||
return allValueMappings.concat(rangeToTextMapping);
|
||||
}
|
||||
|
||||
return allValueMappings;
|
||||
};
|
||||
|
||||
const getAllFormattedValueMappings = (valueMappings: ValueMapping[], value: TimeSeriesValue) => {
|
||||
const allFormattedValueMappings = valueMappings.reduce(
|
||||
(allValueMappings, valueMapping) => {
|
||||
if (valueMapping.type === MappingType.ValueToText) {
|
||||
allValueMappings = addValueToTextMappingText(allValueMappings, valueMapping as ValueMap, value);
|
||||
} else if (valueMapping.type === MappingType.RangeToText) {
|
||||
allValueMappings = addRangeToTextMappingText(allValueMappings, valueMapping as RangeMap, value);
|
||||
}
|
||||
|
||||
return allValueMappings;
|
||||
},
|
||||
[] as ValueMapping[]
|
||||
);
|
||||
|
||||
allFormattedValueMappings.sort((t1, t2) => {
|
||||
return t1.id - t2.id;
|
||||
});
|
||||
|
||||
return allFormattedValueMappings;
|
||||
};
|
||||
|
||||
export const getMappedValue = (valueMappings: ValueMapping[], value: TimeSeriesValue): ValueMapping => {
|
||||
return getAllFormattedValueMappings(valueMappings, value)[0];
|
||||
};
|
@ -165,6 +165,8 @@ func (hs *HTTPServer) getFrontendSettingsMap(c *m.ReqContext) (map[string]interf
|
||||
"externalUserMngInfo": setting.ExternalUserMngInfo,
|
||||
"externalUserMngLinkUrl": setting.ExternalUserMngLinkUrl,
|
||||
"externalUserMngLinkName": setting.ExternalUserMngLinkName,
|
||||
"viewersCanEdit": setting.ViewersCanEdit,
|
||||
"disableSanitizeHtml": hs.Cfg.DisableSanitizeHtml,
|
||||
"buildInfo": map[string]interface{}{
|
||||
"version": setting.BuildVersion,
|
||||
"commit": setting.BuildCommit,
|
||||
|
@ -140,7 +140,7 @@ func (hs *HTTPServer) setIndexViewData(c *m.ReqContext) (*dtos.IndexViewData, er
|
||||
Children: dashboardChildNavs,
|
||||
})
|
||||
|
||||
if setting.ExploreEnabled && (c.OrgRole == m.ROLE_ADMIN || c.OrgRole == m.ROLE_EDITOR) {
|
||||
if setting.ExploreEnabled && (c.OrgRole == m.ROLE_ADMIN || c.OrgRole == m.ROLE_EDITOR || setting.ViewersCanEdit) {
|
||||
data.NavTree = append(data.NavTree, &dtos.NavLink{
|
||||
Text: "Explore",
|
||||
Id: "explore",
|
||||
|
@ -6,7 +6,6 @@ import (
|
||||
"regexp"
|
||||
|
||||
"github.com/grafana/grafana/pkg/log"
|
||||
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
)
|
||||
|
||||
@ -21,6 +20,10 @@ func (NopImageUploader) Upload(ctx context.Context, path string) (string, error)
|
||||
return "", nil
|
||||
}
|
||||
|
||||
var (
|
||||
logger = log.New("imguploader")
|
||||
)
|
||||
|
||||
func NewImageUploader() (ImageUploader, error) {
|
||||
|
||||
switch setting.ImageUploadProvider {
|
||||
@ -94,7 +97,7 @@ func NewImageUploader() (ImageUploader, error) {
|
||||
}
|
||||
|
||||
if setting.ImageUploadProvider != "" {
|
||||
log.Error2("The external image storage configuration is invalid", "unsupported provider", setting.ImageUploadProvider)
|
||||
logger.Error("The external image storage configuration is invalid", "unsupported provider", setting.ImageUploadProvider)
|
||||
}
|
||||
|
||||
return NopImageUploader{}, nil
|
||||
|
@ -10,13 +10,11 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"gopkg.in/ini.v1"
|
||||
|
||||
"github.com/go-stack/stack"
|
||||
"github.com/grafana/grafana/pkg/util"
|
||||
"github.com/inconshreveable/log15"
|
||||
isatty "github.com/mattn/go-isatty"
|
||||
|
||||
"github.com/grafana/grafana/pkg/util"
|
||||
"gopkg.in/ini.v1"
|
||||
)
|
||||
|
||||
var Root log15.Logger
|
||||
@ -58,10 +56,6 @@ func Debug(format string, v ...interface{}) {
|
||||
Root.Debug(message)
|
||||
}
|
||||
|
||||
func Debug2(message string, v ...interface{}) {
|
||||
Root.Debug(message, v...)
|
||||
}
|
||||
|
||||
func Info(format string, v ...interface{}) {
|
||||
var message string
|
||||
if len(v) > 0 {
|
||||
@ -73,10 +67,6 @@ func Info(format string, v ...interface{}) {
|
||||
Root.Info(message)
|
||||
}
|
||||
|
||||
func Info2(message string, v ...interface{}) {
|
||||
Root.Info(message, v...)
|
||||
}
|
||||
|
||||
func Warn(format string, v ...interface{}) {
|
||||
var message string
|
||||
if len(v) > 0 {
|
||||
@ -88,18 +78,10 @@ func Warn(format string, v ...interface{}) {
|
||||
Root.Warn(message)
|
||||
}
|
||||
|
||||
func Warn2(message string, v ...interface{}) {
|
||||
Root.Warn(message, v...)
|
||||
}
|
||||
|
||||
func Error(skip int, format string, v ...interface{}) {
|
||||
Root.Error(fmt.Sprintf(format, v...))
|
||||
}
|
||||
|
||||
func Error2(message string, v ...interface{}) {
|
||||
Root.Error(message, v...)
|
||||
}
|
||||
|
||||
func Critical(skip int, format string, v ...interface{}) {
|
||||
Root.Crit(fmt.Sprintf(format, v...))
|
||||
}
|
||||
|
@ -11,6 +11,10 @@ func init() {
|
||||
bus.AddHandler("auth", UpsertUser)
|
||||
}
|
||||
|
||||
var (
|
||||
logger = log.New("login.ext_user")
|
||||
)
|
||||
|
||||
func UpsertUser(cmd *m.UpsertUserCommand) error {
|
||||
extUser := cmd.ExternalUser
|
||||
|
||||
@ -135,7 +139,7 @@ func updateUser(user *m.User, extUser *m.ExternalUserInfo) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
log.Debug2("Syncing user info", "id", user.Id, "update", updateCmd)
|
||||
logger.Debug("Syncing user info", "id", user.Id, "update", updateCmd)
|
||||
return bus.Dispatch(updateCmd)
|
||||
}
|
||||
|
||||
|
@ -130,7 +130,7 @@ func (this *TelegramNotifier) buildMessageInlineImage(evalContext *alerting.Eval
|
||||
defer func() {
|
||||
err := imageFile.Close()
|
||||
if err != nil {
|
||||
log.Error2("Could not close Telegram inline image.", "err", err)
|
||||
this.log.Error("Could not close Telegram inline image.", "err", err)
|
||||
}
|
||||
}()
|
||||
|
||||
|
@ -18,9 +18,12 @@ type NotificationTestCommand struct {
|
||||
Settings *simplejson.Json
|
||||
}
|
||||
|
||||
var (
|
||||
logger = log.New("alerting.testnotification")
|
||||
)
|
||||
|
||||
func init() {
|
||||
bus.AddHandler("alerting", handleNotificationTestCommand)
|
||||
|
||||
}
|
||||
|
||||
func handleNotificationTestCommand(cmd *NotificationTestCommand) error {
|
||||
@ -35,7 +38,7 @@ func handleNotificationTestCommand(cmd *NotificationTestCommand) error {
|
||||
notifiers, err := InitNotifier(model)
|
||||
|
||||
if err != nil {
|
||||
log.Error2("Failed to create notifier", "error", err.Error())
|
||||
logger.Error("Failed to create notifier", "error", err.Error())
|
||||
return err
|
||||
}
|
||||
|
||||
|
@ -222,6 +222,7 @@ type Cfg struct {
|
||||
MetricsEndpointBasicAuthUsername string
|
||||
MetricsEndpointBasicAuthPassword string
|
||||
EnableAlphaPanels bool
|
||||
DisableSanitizeHtml bool
|
||||
EnterpriseLicensePath string
|
||||
|
||||
LoginCookieName string
|
||||
@ -723,6 +724,7 @@ func (cfg *Cfg) Load(args *CommandLineArgs) error {
|
||||
|
||||
panels := iniFile.Section("panels")
|
||||
cfg.EnableAlphaPanels = panels.Key("enable_alpha").MustBool(false)
|
||||
cfg.DisableSanitizeHtml = panels.Key("disable_sanitize_html").MustBool(false)
|
||||
|
||||
cfg.readSessionConfig()
|
||||
cfg.readSmtpSettings()
|
||||
|
@ -1,4 +1,4 @@
|
||||
import React, { SFC } from 'react';
|
||||
import React, { FC } from 'react';
|
||||
import Transition from 'react-transition-group/Transition';
|
||||
|
||||
interface Props {
|
||||
@ -8,7 +8,7 @@ interface Props {
|
||||
unmountOnExit?: boolean;
|
||||
}
|
||||
|
||||
export const FadeIn: SFC<Props> = props => {
|
||||
export const FadeIn: FC<Props> = props => {
|
||||
const defaultStyle = {
|
||||
transition: `opacity ${props.duration}ms linear`,
|
||||
opacity: 0,
|
||||
|
50
public/app/core/components/Footer/Footer.tsx
Normal file
50
public/app/core/components/Footer/Footer.tsx
Normal file
@ -0,0 +1,50 @@
|
||||
import React, { FC } from 'react';
|
||||
import { Tooltip } from '@grafana/ui';
|
||||
|
||||
interface Props {
|
||||
appName: string;
|
||||
buildVersion: string;
|
||||
buildCommit: string;
|
||||
newGrafanaVersionExists: boolean;
|
||||
newGrafanaVersion: string;
|
||||
}
|
||||
|
||||
export const Footer: FC<Props> = React.memo(({appName, buildVersion, buildCommit, newGrafanaVersionExists, newGrafanaVersion}) => {
|
||||
return (
|
||||
<footer className="footer">
|
||||
<div className="text-center">
|
||||
<ul>
|
||||
<li>
|
||||
<a href="http://docs.grafana.org" target="_blank">
|
||||
<i className="fa fa-file-code-o" /> Docs
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://grafana.com/services/support" target="_blank">
|
||||
<i className="fa fa-support" /> Support Plans
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://community.grafana.com/" target="_blank">
|
||||
<i className="fa fa-comments-o" /> Community
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://grafana.com" target="_blank">{appName}</a> <span>v{buildVersion} (commit: {buildCommit})</span>
|
||||
</li>
|
||||
{newGrafanaVersionExists && (
|
||||
<li>
|
||||
<Tooltip placement="auto" content={newGrafanaVersion}>
|
||||
<a href="https://grafana.com/get" target="_blank">
|
||||
New version available!
|
||||
</a>
|
||||
</Tooltip>
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
});
|
||||
|
||||
export default Footer;
|
@ -1,25 +0,0 @@
|
||||
import React, { SFC, ReactNode } from 'react';
|
||||
import { Tooltip } from '@grafana/ui';
|
||||
|
||||
interface Props {
|
||||
tooltip?: string;
|
||||
for?: string;
|
||||
children: ReactNode;
|
||||
width?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const Label: SFC<Props> = props => {
|
||||
return (
|
||||
<span className={`gf-form-label width-${props.width ? props.width : '10'}`}>
|
||||
<span>{props.children}</span>
|
||||
{props.tooltip && (
|
||||
<Tooltip placement="auto" content={props.tooltip}>
|
||||
<div className="gf-form-help-icon--right-normal">
|
||||
<i className="gicon gicon-question gicon--has-hover" />
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
};
|
@ -1,4 +1,4 @@
|
||||
import React, { SFC } from 'react';
|
||||
import React, { FC } from 'react';
|
||||
|
||||
export type LayoutMode = LayoutModes.Grid | LayoutModes.List;
|
||||
|
||||
@ -12,7 +12,7 @@ interface Props {
|
||||
onLayoutModeChanged: (mode: LayoutMode) => {};
|
||||
}
|
||||
|
||||
const LayoutSelector: SFC<Props> = props => {
|
||||
const LayoutSelector: FC<Props> = props => {
|
||||
const { mode, onLayoutModeChanged } = props;
|
||||
return (
|
||||
<div className="layout-selector">
|
||||
|
75
public/app/core/components/Page/Page.tsx
Normal file
75
public/app/core/components/Page/Page.tsx
Normal file
@ -0,0 +1,75 @@
|
||||
// Libraries
|
||||
import React, { Component } from 'react';
|
||||
import config from 'app/core/config';
|
||||
import { NavModel } from 'app/types';
|
||||
import { getTitleFromNavModel } from 'app/core/selectors/navModel';
|
||||
|
||||
// Components
|
||||
import PageHeader from '../PageHeader/PageHeader';
|
||||
import Footer from '../Footer/Footer';
|
||||
import PageContents from './PageContents';
|
||||
import { CustomScrollbar } from '@grafana/ui';
|
||||
|
||||
interface Props {
|
||||
title?: string;
|
||||
children: JSX.Element[] | JSX.Element;
|
||||
navModel: NavModel;
|
||||
}
|
||||
|
||||
class Page extends Component<Props> {
|
||||
private bodyClass = 'is-react';
|
||||
private body = document.body;
|
||||
static Header = PageHeader;
|
||||
static Contents = PageContents;
|
||||
|
||||
componentDidMount() {
|
||||
this.body.classList.add(this.bodyClass);
|
||||
this.updateTitle();
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps: Props) {
|
||||
if (prevProps.title !== this.props.title) {
|
||||
this.updateTitle();
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.body.classList.remove(this.bodyClass);
|
||||
}
|
||||
|
||||
updateTitle = () => {
|
||||
const title = this.getPageTitle;
|
||||
document.title = title ? title + ' - Grafana' : 'Grafana';
|
||||
}
|
||||
|
||||
get getPageTitle () {
|
||||
const { navModel } = this.props;
|
||||
if (navModel) {
|
||||
return getTitleFromNavModel(navModel) || undefined;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
render() {
|
||||
const { navModel } = this.props;
|
||||
const { buildInfo } = config;
|
||||
return (
|
||||
<div className="page-scrollbar-wrapper">
|
||||
<CustomScrollbar autoHeightMin={'100%'}>
|
||||
<div className="page-scrollbar-content">
|
||||
<PageHeader model={navModel} />
|
||||
{this.props.children}
|
||||
<Footer
|
||||
appName="Grafana"
|
||||
buildCommit={buildInfo.commit}
|
||||
buildVersion={buildInfo.version}
|
||||
newGrafanaVersion={buildInfo.latestVersion}
|
||||
newGrafanaVersionExists={buildInfo.hasUpdate} />
|
||||
</div>
|
||||
</CustomScrollbar>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default Page;
|
26
public/app/core/components/Page/PageContents.tsx
Normal file
26
public/app/core/components/Page/PageContents.tsx
Normal file
@ -0,0 +1,26 @@
|
||||
// Libraries
|
||||
import React, { Component } from 'react';
|
||||
|
||||
// Components
|
||||
import PageLoader from '../PageLoader/PageLoader';
|
||||
|
||||
interface Props {
|
||||
isLoading?: boolean;
|
||||
children: JSX.Element[] | JSX.Element;
|
||||
}
|
||||
|
||||
class PageContents extends Component<Props> {
|
||||
|
||||
render() {
|
||||
const { isLoading } = this.props;
|
||||
|
||||
return (
|
||||
<div className="page-container page-body">
|
||||
{isLoading && <PageLoader />}
|
||||
{this.props.children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default PageContents;
|
@ -1,4 +1,4 @@
|
||||
import React from 'react';
|
||||
import React, { FormEvent } from 'react';
|
||||
import { NavModel, NavModelItem } from 'app/types';
|
||||
import classNames from 'classnames';
|
||||
import appEvents from 'app/core/app_events';
|
||||
@ -12,8 +12,8 @@ const SelectNav = ({ main, customCss }: { main: NavModelItem; customCss: string
|
||||
return navItem.active === true;
|
||||
});
|
||||
|
||||
const gotoUrl = evt => {
|
||||
const element = evt.target;
|
||||
const gotoUrl = (evt: FormEvent) => {
|
||||
const element = evt.target as HTMLSelectElement;
|
||||
const url = element.options[element.selectedIndex].value;
|
||||
appEvents.emit('location-change', { href: url });
|
||||
};
|
||||
|
@ -1,10 +1,10 @@
|
||||
import React, { SFC } from 'react';
|
||||
import React, { FC } from 'react';
|
||||
|
||||
interface Props {
|
||||
pageName: string;
|
||||
pageName?: string;
|
||||
}
|
||||
|
||||
const PageLoader: SFC<Props> = ({ pageName }) => {
|
||||
const PageLoader: FC<Props> = ({ pageName }) => {
|
||||
const loadingText = `Loading ${pageName}...`;
|
||||
return (
|
||||
<div className="page-loader-wrapper">
|
||||
|
@ -6,7 +6,7 @@ import _ from 'lodash';
|
||||
import { Select } from '@grafana/ui';
|
||||
|
||||
// Types
|
||||
import { DataSourceSelectItem } from 'app/types';
|
||||
import { DataSourceSelectItem } from '@grafana/ui/src/types';
|
||||
|
||||
export interface Props {
|
||||
onChange: (ds: DataSourceSelectItem) => void;
|
||||
|
@ -1,7 +1,6 @@
|
||||
import React, { PureComponent } from 'react';
|
||||
|
||||
import { Label } from 'app/core/components/Label/Label';
|
||||
import { Select } from '@grafana/ui';
|
||||
import { FormLabel, Select } from '@grafana/ui';
|
||||
import { getBackendSrv, BackendSrv } from 'app/core/services/backend_srv';
|
||||
|
||||
import { DashboardSearchHit } from 'app/types';
|
||||
@ -100,12 +99,12 @@ export class SharedPreferences extends PureComponent<Props, State> {
|
||||
/>
|
||||
</div>
|
||||
<div className="gf-form">
|
||||
<Label
|
||||
<FormLabel
|
||||
width={11}
|
||||
tooltip="Not finding dashboard you want? Star it first, then it should appear in this select box."
|
||||
>
|
||||
Home Dashboard
|
||||
</Label>
|
||||
</FormLabel>
|
||||
<Select
|
||||
value={dashboards.find(dashboard => dashboard.id === homeDashboardId)}
|
||||
getOptionValue={i => i.id}
|
||||
|
@ -1,4 +1,4 @@
|
||||
import React, { SFC, ReactNode, PureComponent } from 'react';
|
||||
import React, { FC, ReactNode, PureComponent } from 'react';
|
||||
import { Tooltip } from '@grafana/ui';
|
||||
|
||||
interface ToggleButtonGroupProps {
|
||||
@ -29,7 +29,7 @@ interface ToggleButtonProps {
|
||||
tooltip?: string;
|
||||
}
|
||||
|
||||
export const ToggleButton: SFC<ToggleButtonProps> = ({
|
||||
export const ToggleButton: FC<ToggleButtonProps> = ({
|
||||
children,
|
||||
selected,
|
||||
className = '',
|
||||
|
@ -1,10 +1,10 @@
|
||||
import React, { SFC } from 'react';
|
||||
import React, { FC } from 'react';
|
||||
|
||||
export interface Props {
|
||||
child: any;
|
||||
}
|
||||
|
||||
const DropDownChild: SFC<Props> = props => {
|
||||
const DropDownChild: FC<Props> = props => {
|
||||
const { child } = props;
|
||||
const listItemClassName = child.divider ? 'divider' : '';
|
||||
|
||||
|
@ -1,16 +1,18 @@
|
||||
import React, { SFC } from 'react';
|
||||
import React, { FC } from 'react';
|
||||
import DropDownChild from './DropDownChild';
|
||||
|
||||
interface Props {
|
||||
link: any;
|
||||
}
|
||||
|
||||
const SideMenuDropDown: SFC<Props> = props => {
|
||||
const SideMenuDropDown: FC<Props> = props => {
|
||||
const { link } = props;
|
||||
return (
|
||||
<ul className="dropdown-menu dropdown-menu--sidemenu" role="menu">
|
||||
<li className="side-menu-header">
|
||||
<span className="sidemenu-item-text">{link.text}</span>
|
||||
<a href={link.url}>
|
||||
<span className="sidemenu-item-text">{link.text}</span>
|
||||
</a>
|
||||
</li>
|
||||
{link.children &&
|
||||
link.children.map((child, index) => {
|
||||
|
@ -1,6 +1,6 @@
|
||||
import React, { SFC } from 'react';
|
||||
import React, { FC } from 'react';
|
||||
|
||||
const SignIn: SFC<any> = () => {
|
||||
const SignIn: FC<any> = () => {
|
||||
const loginUrl = `login?redirect=${encodeURIComponent(window.location.pathname)}`;
|
||||
return (
|
||||
<div className="sidemenu-item">
|
||||
|
@ -1,9 +1,9 @@
|
||||
import React, { SFC } from 'react';
|
||||
import React, { FC } from 'react';
|
||||
import _ from 'lodash';
|
||||
import TopSectionItem from './TopSectionItem';
|
||||
import config from '../../config';
|
||||
|
||||
const TopSection: SFC<any> = () => {
|
||||
const TopSection: FC<any> = () => {
|
||||
const navTree = _.cloneDeep(config.bootData.navTree);
|
||||
const mainLinks = _.filter(navTree, item => !item.hideFromMenu);
|
||||
|
||||
|
@ -1,11 +1,11 @@
|
||||
import React, { SFC } from 'react';
|
||||
import React, { FC } from 'react';
|
||||
import SideMenuDropDown from './SideMenuDropDown';
|
||||
|
||||
export interface Props {
|
||||
link: any;
|
||||
}
|
||||
|
||||
const TopSectionItem: SFC<Props> = props => {
|
||||
const TopSectionItem: FC<Props> = props => {
|
||||
const { link } = props;
|
||||
return (
|
||||
<div className="sidemenu-item dropdown">
|
||||
|
@ -8,11 +8,13 @@ exports[`Render should render children 1`] = `
|
||||
<li
|
||||
className="side-menu-header"
|
||||
>
|
||||
<span
|
||||
className="sidemenu-item-text"
|
||||
>
|
||||
link
|
||||
</span>
|
||||
<a>
|
||||
<span
|
||||
className="sidemenu-item-text"
|
||||
>
|
||||
link
|
||||
</span>
|
||||
</a>
|
||||
</li>
|
||||
<DropDownChild
|
||||
child={
|
||||
@ -49,11 +51,13 @@ exports[`Render should render component 1`] = `
|
||||
<li
|
||||
className="side-menu-header"
|
||||
>
|
||||
<span
|
||||
className="sidemenu-item-text"
|
||||
>
|
||||
link
|
||||
</span>
|
||||
<a>
|
||||
<span
|
||||
className="sidemenu-item-text"
|
||||
>
|
||||
link
|
||||
</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
`;
|
||||
|
@ -6,6 +6,8 @@ export interface BuildInfo {
|
||||
commit: string;
|
||||
isEnterprise: boolean;
|
||||
env: string;
|
||||
latestVersion: string;
|
||||
hasUpdate: boolean;
|
||||
}
|
||||
|
||||
export class Settings {
|
||||
@ -32,8 +34,10 @@ export class Settings {
|
||||
disableUserSignUp: boolean;
|
||||
loginHint: any;
|
||||
loginError: any;
|
||||
viewersCanEdit: boolean;
|
||||
disableSanitizeHtml: boolean;
|
||||
|
||||
constructor(options) {
|
||||
constructor(options: Settings) {
|
||||
const defaults = {
|
||||
datasources: {},
|
||||
windowTitlePrefix: 'Grafana - ',
|
||||
@ -48,6 +52,8 @@ export class Settings {
|
||||
env: 'production',
|
||||
isEnterprise: false,
|
||||
},
|
||||
viewersCanEdit: false,
|
||||
disableSanitizeHtml: false
|
||||
};
|
||||
|
||||
_.extend(this, defaults, options);
|
||||
|
@ -1,5 +1,6 @@
|
||||
import './directives/dash_class';
|
||||
import './directives/dropdown_typeahead';
|
||||
import './directives/autofill_event_fix';
|
||||
import './directives/metric_segment';
|
||||
import './directives/misc';
|
||||
import './directives/ng_model_on_blur';
|
||||
|
35
public/app/core/directives/autofill_event_fix.ts
Normal file
35
public/app/core/directives/autofill_event_fix.ts
Normal file
@ -0,0 +1,35 @@
|
||||
import coreModule from '../core_module';
|
||||
|
||||
/** @ngInject */
|
||||
export function autofillEventFix($compile) {
|
||||
return {
|
||||
link: ($scope: any, elem: any) => {
|
||||
const input = elem[0];
|
||||
const dispatchChangeEvent = () => {
|
||||
const event = new Event('change');
|
||||
return input.dispatchEvent(event);
|
||||
};
|
||||
const onAnimationStart = ({ animationName }: AnimationEvent) => {
|
||||
switch (animationName) {
|
||||
case 'onAutoFillStart':
|
||||
return dispatchChangeEvent();
|
||||
case 'onAutoFillCancel':
|
||||
return dispatchChangeEvent();
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
// const onChange = (evt: Event) => console.log(evt);
|
||||
|
||||
input.addEventListener('animationstart', onAnimationStart);
|
||||
// input.addEventListener('change', onChange);
|
||||
|
||||
$scope.$on('$destroy', () => {
|
||||
input.removeEventListener('animationstart', onAnimationStart);
|
||||
// input.removeEventListener('change', onChange);
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
coreModule.directive('autofillEventFix', autofillEventFix);
|
@ -141,6 +141,9 @@ export function dropdownTypeahead2($compile) {
|
||||
link: ($scope, elem, attrs) => {
|
||||
const $input = $(inputTemplate);
|
||||
const $button = $(buttonTemplate);
|
||||
const timeoutId = {
|
||||
blur: null
|
||||
};
|
||||
$input.appendTo(elem);
|
||||
$button.appendTo(elem);
|
||||
|
||||
@ -177,6 +180,14 @@ export function dropdownTypeahead2($compile) {
|
||||
[]
|
||||
);
|
||||
|
||||
const closeDropdownMenu = () => {
|
||||
$input.hide();
|
||||
$input.val('');
|
||||
$button.show();
|
||||
$button.focus();
|
||||
elem.removeClass('open');
|
||||
};
|
||||
|
||||
$scope.menuItemSelected = (index, subIndex) => {
|
||||
const menuItem = $scope.menuItems[index];
|
||||
const payload: any = { $item: menuItem };
|
||||
@ -184,6 +195,7 @@ export function dropdownTypeahead2($compile) {
|
||||
payload.$subItem = menuItem.submenu[subIndex];
|
||||
}
|
||||
$scope.dropdownTypeaheadOnSelect(payload);
|
||||
closeDropdownMenu();
|
||||
};
|
||||
|
||||
$input.attr('data-provide', 'typeahead');
|
||||
@ -223,16 +235,15 @@ export function dropdownTypeahead2($compile) {
|
||||
elem.toggleClass('open', $input.val() === '');
|
||||
});
|
||||
|
||||
elem.mousedown((evt: Event) => {
|
||||
evt.preventDefault();
|
||||
timeoutId.blur = null;
|
||||
});
|
||||
|
||||
$input.blur(() => {
|
||||
$input.hide();
|
||||
$input.val('');
|
||||
$button.show();
|
||||
$button.focus();
|
||||
// clicking the function dropdown menu won't
|
||||
// work if you remove class at once
|
||||
setTimeout(() => {
|
||||
elem.removeClass('open');
|
||||
}, 200);
|
||||
timeoutId.blur = setTimeout(() => {
|
||||
closeDropdownMenu();
|
||||
}, 1);
|
||||
});
|
||||
|
||||
$compile(elem.contents())($scope);
|
||||
|
@ -42,7 +42,7 @@ export interface LogSearchMatch {
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface LogRow {
|
||||
export interface LogRowModel {
|
||||
duplicates?: number;
|
||||
entry: string;
|
||||
key: string; // timestamp + labels
|
||||
@ -56,7 +56,7 @@ export interface LogRow {
|
||||
uniqueLabels?: LogsStreamLabels;
|
||||
}
|
||||
|
||||
export interface LogsLabelStat {
|
||||
export interface LogLabelStatsModel {
|
||||
active?: boolean;
|
||||
count: number;
|
||||
proportion: number;
|
||||
@ -78,7 +78,7 @@ export interface LogsMetaItem {
|
||||
export interface LogsModel {
|
||||
id: string; // Identify one logs result from another
|
||||
meta?: LogsMetaItem[];
|
||||
rows: LogRow[];
|
||||
rows: LogRowModel[];
|
||||
series?: TimeSeries[];
|
||||
}
|
||||
|
||||
@ -188,13 +188,13 @@ export const LogsParsers: { [name: string]: LogsParser } = {
|
||||
},
|
||||
};
|
||||
|
||||
export function calculateFieldStats(rows: LogRow[], extractor: RegExp): LogsLabelStat[] {
|
||||
export function calculateFieldStats(rows: LogRowModel[], extractor: RegExp): LogLabelStatsModel[] {
|
||||
// Consider only rows that satisfy the matcher
|
||||
const rowsWithField = rows.filter(row => extractor.test(row.entry));
|
||||
const rowCount = rowsWithField.length;
|
||||
|
||||
// Get field value counts for eligible rows
|
||||
const countsByValue = _.countBy(rowsWithField, row => (row as LogRow).entry.match(extractor)[1]);
|
||||
const countsByValue = _.countBy(rowsWithField, row => (row as LogRowModel).entry.match(extractor)[1]);
|
||||
const sortedCounts = _.chain(countsByValue)
|
||||
.map((count, value) => ({ count, value, proportion: count / rowCount }))
|
||||
.sortBy('count')
|
||||
@ -204,13 +204,13 @@ export function calculateFieldStats(rows: LogRow[], extractor: RegExp): LogsLabe
|
||||
return sortedCounts;
|
||||
}
|
||||
|
||||
export function calculateLogsLabelStats(rows: LogRow[], label: string): LogsLabelStat[] {
|
||||
export function calculateLogsLabelStats(rows: LogRowModel[], label: string): LogLabelStatsModel[] {
|
||||
// Consider only rows that have the given label
|
||||
const rowsWithLabel = rows.filter(row => row.labels[label] !== undefined);
|
||||
const rowCount = rowsWithLabel.length;
|
||||
|
||||
// Get label value counts for eligible rows
|
||||
const countsByValue = _.countBy(rowsWithLabel, row => (row as LogRow).labels[label]);
|
||||
const countsByValue = _.countBy(rowsWithLabel, row => (row as LogRowModel).labels[label]);
|
||||
const sortedCounts = _.chain(countsByValue)
|
||||
.map((count, value) => ({ count, value, proportion: count / rowCount }))
|
||||
.sortBy('count')
|
||||
@ -221,7 +221,7 @@ export function calculateLogsLabelStats(rows: LogRow[], label: string): LogsLabe
|
||||
}
|
||||
|
||||
const isoDateRegexp = /\d{4}-[01]\d-[0-3]\dT[0-2]\d:[0-5]\d:[0-6]\d[,\.]\d+([+-][0-2]\d:[0-5]\d|Z)/g;
|
||||
function isDuplicateRow(row: LogRow, other: LogRow, strategy: LogsDedupStrategy): boolean {
|
||||
function isDuplicateRow(row: LogRowModel, other: LogRowModel, strategy: LogsDedupStrategy): boolean {
|
||||
switch (strategy) {
|
||||
case LogsDedupStrategy.exact:
|
||||
// Exact still strips dates
|
||||
@ -243,7 +243,7 @@ export function dedupLogRows(logs: LogsModel, strategy: LogsDedupStrategy): Logs
|
||||
return logs;
|
||||
}
|
||||
|
||||
const dedupedRows = logs.rows.reduce((result: LogRow[], row: LogRow, index, list) => {
|
||||
const dedupedRows = logs.rows.reduce((result: LogRowModel[], row: LogRowModel, index, list) => {
|
||||
const previous = result[result.length - 1];
|
||||
if (index > 0 && isDuplicateRow(row, previous, strategy)) {
|
||||
previous.duplicates++;
|
||||
@ -278,7 +278,7 @@ export function filterLogLevels(logs: LogsModel, hiddenLogLevels: Set<LogLevel>)
|
||||
return logs;
|
||||
}
|
||||
|
||||
const filteredRows = logs.rows.reduce((result: LogRow[], row: LogRow, index, list) => {
|
||||
const filteredRows = logs.rows.reduce((result: LogRowModel[], row: LogRowModel, index, list) => {
|
||||
if (!hiddenLogLevels.has(row.logLevel)) {
|
||||
result.push(row);
|
||||
}
|
||||
@ -291,7 +291,7 @@ export function filterLogLevels(logs: LogsModel, hiddenLogLevels: Set<LogLevel>)
|
||||
};
|
||||
}
|
||||
|
||||
export function makeSeriesForLogs(rows: LogRow[], intervalMs: number): TimeSeries[] {
|
||||
export function makeSeriesForLogs(rows: LogRowModel[], intervalMs: number): TimeSeries[] {
|
||||
// currently interval is rangeMs / resolution, which is too low for showing series as bars.
|
||||
// need at least 10px per bucket, so we multiply interval by 10. Should be solved higher up the chain
|
||||
// when executing queries & interval calculated and not here but this is a temporary fix.
|
||||
|
@ -41,3 +41,7 @@ export function getNavModel(navIndex: NavIndex, id: string, fallback?: NavModel)
|
||||
|
||||
return getNotFoundModel();
|
||||
}
|
||||
|
||||
export const getTitleFromNavModel = (navModel: NavModel) => {
|
||||
return `${navModel.main.text}${navModel.node.text ? ': ' + navModel.node.text : '' }`;
|
||||
};
|
||||
|
@ -2,6 +2,7 @@ import config from 'app/core/config';
|
||||
import _ from 'lodash';
|
||||
import coreModule from 'app/core/core_module';
|
||||
import store from 'app/core/store';
|
||||
import { ThemeNames, ThemeName } from '@grafana/ui';
|
||||
|
||||
export class User {
|
||||
isGrafanaAdmin: any;
|
||||
@ -59,6 +60,14 @@ export class ContextSrv {
|
||||
this.sidemenu = !this.sidemenu;
|
||||
store.set('grafana.sidemenu', this.sidemenu);
|
||||
}
|
||||
|
||||
hasAccessToExplore() {
|
||||
return (this.isEditor || config.viewersCanEdit) && config.exploreEnabled;
|
||||
}
|
||||
|
||||
getTheme(): ThemeName {
|
||||
return this.user.lightTheme ? ThemeNames.Light : ThemeNames.Dark;
|
||||
}
|
||||
}
|
||||
|
||||
const contextSrv = new ContextSrv();
|
||||
|
@ -1,13 +1,13 @@
|
||||
import $ from 'jquery';
|
||||
import _ from 'lodash';
|
||||
|
||||
import config from 'app/core/config';
|
||||
import coreModule from 'app/core/core_module';
|
||||
import appEvents from 'app/core/app_events';
|
||||
import { getExploreUrl } from 'app/core/utils/explore';
|
||||
|
||||
import Mousetrap from 'mousetrap';
|
||||
import 'mousetrap-global-bind';
|
||||
import { ContextSrv } from './context_srv';
|
||||
|
||||
export class KeybindingSrv {
|
||||
helpModal: boolean;
|
||||
@ -21,7 +21,7 @@ export class KeybindingSrv {
|
||||
private $timeout,
|
||||
private datasourceSrv,
|
||||
private timeSrv,
|
||||
private contextSrv
|
||||
private contextSrv: ContextSrv
|
||||
) {
|
||||
// clear out all shortcuts on route change
|
||||
$rootScope.$on('$routeChangeSuccess', () => {
|
||||
@ -196,7 +196,7 @@ export class KeybindingSrv {
|
||||
});
|
||||
|
||||
// jump to explore if permissions allow
|
||||
if (this.contextSrv.isEditor && config.exploreEnabled) {
|
||||
if (this.contextSrv.hasAccessToExplore()) {
|
||||
this.bind('x', async () => {
|
||||
if (dashboard.meta.focusPanelId) {
|
||||
const panel = dashboard.getPanelById(dashboard.meta.focusPanelId);
|
||||
|
@ -6,26 +6,13 @@ import {
|
||||
clearHistory,
|
||||
hasNonEmptyQuery,
|
||||
} from './explore';
|
||||
import { ExploreState } from 'app/types/explore';
|
||||
import { ExploreUrlState } from 'app/types/explore';
|
||||
import store from 'app/core/store';
|
||||
|
||||
const DEFAULT_EXPLORE_STATE: ExploreState = {
|
||||
const DEFAULT_EXPLORE_STATE: ExploreUrlState = {
|
||||
datasource: null,
|
||||
datasourceError: null,
|
||||
datasourceLoading: null,
|
||||
datasourceMissing: false,
|
||||
exploreDatasources: [],
|
||||
graphInterval: 1000,
|
||||
history: [],
|
||||
initialQueries: [],
|
||||
queryTransactions: [],
|
||||
queries: [],
|
||||
range: DEFAULT_RANGE,
|
||||
showingGraph: true,
|
||||
showingLogs: true,
|
||||
showingTable: true,
|
||||
supportsGraph: null,
|
||||
supportsLogs: null,
|
||||
supportsTable: null,
|
||||
};
|
||||
|
||||
describe('state functions', () => {
|
||||
@ -68,21 +55,19 @@ describe('state functions', () => {
|
||||
it('returns url parameter value for a state object', () => {
|
||||
const state = {
|
||||
...DEFAULT_EXPLORE_STATE,
|
||||
initialDatasource: 'foo',
|
||||
datasource: 'foo',
|
||||
queries: [
|
||||
{
|
||||
expr: 'metric{test="a/b"}',
|
||||
},
|
||||
{
|
||||
expr: 'super{foo="x/z"}',
|
||||
},
|
||||
],
|
||||
range: {
|
||||
from: 'now-5h',
|
||||
to: 'now',
|
||||
},
|
||||
initialQueries: [
|
||||
{
|
||||
refId: '1',
|
||||
expr: 'metric{test="a/b"}',
|
||||
},
|
||||
{
|
||||
refId: '2',
|
||||
expr: 'super{foo="x/z"}',
|
||||
},
|
||||
],
|
||||
};
|
||||
expect(serializeStateToUrlParam(state)).toBe(
|
||||
'{"datasource":"foo","queries":[{"expr":"metric{test=\\"a/b\\"}"},' +
|
||||
@ -93,21 +78,19 @@ describe('state functions', () => {
|
||||
it('returns url parameter value for a state object', () => {
|
||||
const state = {
|
||||
...DEFAULT_EXPLORE_STATE,
|
||||
initialDatasource: 'foo',
|
||||
datasource: 'foo',
|
||||
queries: [
|
||||
{
|
||||
expr: 'metric{test="a/b"}',
|
||||
},
|
||||
{
|
||||
expr: 'super{foo="x/z"}',
|
||||
},
|
||||
],
|
||||
range: {
|
||||
from: 'now-5h',
|
||||
to: 'now',
|
||||
},
|
||||
initialQueries: [
|
||||
{
|
||||
refId: '1',
|
||||
expr: 'metric{test="a/b"}',
|
||||
},
|
||||
{
|
||||
refId: '2',
|
||||
expr: 'super{foo="x/z"}',
|
||||
},
|
||||
],
|
||||
};
|
||||
expect(serializeStateToUrlParam(state, true)).toBe(
|
||||
'["now-5h","now","foo",{"expr":"metric{test=\\"a/b\\"}"},{"expr":"super{foo=\\"x/z\\"}"}]'
|
||||
@ -119,35 +102,24 @@ describe('state functions', () => {
|
||||
it('can parse the serialized state into the original state', () => {
|
||||
const state = {
|
||||
...DEFAULT_EXPLORE_STATE,
|
||||
initialDatasource: 'foo',
|
||||
datasource: 'foo',
|
||||
queries: [
|
||||
{
|
||||
expr: 'metric{test="a/b"}',
|
||||
},
|
||||
{
|
||||
expr: 'super{foo="x/z"}',
|
||||
},
|
||||
],
|
||||
range: {
|
||||
from: 'now - 5h',
|
||||
to: 'now',
|
||||
},
|
||||
initialQueries: [
|
||||
{
|
||||
refId: '1',
|
||||
expr: 'metric{test="a/b"}',
|
||||
},
|
||||
{
|
||||
refId: '2',
|
||||
expr: 'super{foo="x/z"}',
|
||||
},
|
||||
],
|
||||
};
|
||||
const serialized = serializeStateToUrlParam(state);
|
||||
const parsed = parseUrlState(serialized);
|
||||
|
||||
// Account for datasource vs datasourceName
|
||||
const { datasource, queries, ...rest } = parsed;
|
||||
const resultState = {
|
||||
...rest,
|
||||
datasource: DEFAULT_EXPLORE_STATE.datasource,
|
||||
initialDatasource: datasource,
|
||||
initialQueries: queries,
|
||||
};
|
||||
|
||||
expect(state).toMatchObject(resultState);
|
||||
expect(state).toMatchObject(parsed);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -1,16 +1,26 @@
|
||||
// Libraries
|
||||
import _ from 'lodash';
|
||||
import { colors } from '@grafana/ui';
|
||||
|
||||
// Services & Utils
|
||||
import * as dateMath from 'app/core/utils/datemath';
|
||||
import { renderUrl } from 'app/core/utils/url';
|
||||
import kbn from 'app/core/utils/kbn';
|
||||
import store from 'app/core/store';
|
||||
import { parse as parseDate } from 'app/core/utils/datemath';
|
||||
|
||||
import TimeSeries from 'app/core/time_series2';
|
||||
import { colors } from '@grafana/ui';
|
||||
import TableModel, { mergeTablesIntoModel } from 'app/core/table_model';
|
||||
import { ExploreState, ExploreUrlState, HistoryItem, QueryTransaction } from 'app/types/explore';
|
||||
import { DataQuery, DataSourceApi } from 'app/types/series';
|
||||
import { RawTimeRange, IntervalValues } from '@grafana/ui';
|
||||
|
||||
// Types
|
||||
import { RawTimeRange, IntervalValues, DataQuery } from '@grafana/ui/src/types';
|
||||
import TimeSeries from 'app/core/time_series2';
|
||||
import {
|
||||
ExploreUrlState,
|
||||
HistoryItem,
|
||||
QueryTransaction,
|
||||
ResultType,
|
||||
QueryIntervals,
|
||||
QueryOptions,
|
||||
} from 'app/types/explore';
|
||||
|
||||
export const DEFAULT_RANGE = {
|
||||
from: 'now-6h',
|
||||
@ -19,6 +29,8 @@ export const DEFAULT_RANGE = {
|
||||
|
||||
const MAX_HISTORY_ITEMS = 100;
|
||||
|
||||
export const LAST_USED_DATASOURCE_KEY = 'grafana.explore.datasource';
|
||||
|
||||
/**
|
||||
* Returns an Explore-URL that contains a panel's queries and the dashboard time range.
|
||||
*
|
||||
@ -77,7 +89,63 @@ export async function getExploreUrl(
|
||||
return url;
|
||||
}
|
||||
|
||||
const clearQueryKeys: ((query: DataQuery) => object) = ({ key, refId, ...rest }) => rest;
|
||||
export function buildQueryTransaction(
|
||||
query: DataQuery,
|
||||
rowIndex: number,
|
||||
resultType: ResultType,
|
||||
queryOptions: QueryOptions,
|
||||
range: RawTimeRange,
|
||||
queryIntervals: QueryIntervals,
|
||||
scanning: boolean
|
||||
): QueryTransaction {
|
||||
const { interval, intervalMs } = queryIntervals;
|
||||
|
||||
const configuredQueries = [
|
||||
{
|
||||
...query,
|
||||
...queryOptions,
|
||||
},
|
||||
];
|
||||
|
||||
// Clone range for query request
|
||||
// const queryRange: RawTimeRange = { ...range };
|
||||
// const { from, to, raw } = this.timeSrv.timeRange();
|
||||
// Most datasource is using `panelId + query.refId` for cancellation logic.
|
||||
// Using `format` here because it relates to the view panel that the request is for.
|
||||
// However, some datasources don't use `panelId + query.refId`, but only `panelId`.
|
||||
// Therefore panel id has to be unique.
|
||||
const panelId = `${queryOptions.format}-${query.key}`;
|
||||
|
||||
const options = {
|
||||
interval,
|
||||
intervalMs,
|
||||
panelId,
|
||||
targets: configuredQueries, // Datasources rely on DataQueries being passed under the targets key.
|
||||
range: {
|
||||
from: dateMath.parse(range.from, false),
|
||||
to: dateMath.parse(range.to, true),
|
||||
raw: range,
|
||||
},
|
||||
rangeRaw: range,
|
||||
scopedVars: {
|
||||
__interval: { text: interval, value: interval },
|
||||
__interval_ms: { text: intervalMs, value: intervalMs },
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
options,
|
||||
query,
|
||||
resultType,
|
||||
rowIndex,
|
||||
scanning,
|
||||
id: generateKey(), // reusing for unique ID
|
||||
done: false,
|
||||
latency: 0,
|
||||
};
|
||||
}
|
||||
|
||||
export const clearQueryKeys: ((query: DataQuery) => object) = ({ key, refId, ...rest }) => rest;
|
||||
|
||||
export function parseUrlState(initial: string | undefined): ExploreUrlState {
|
||||
if (initial) {
|
||||
@ -103,12 +171,7 @@ export function parseUrlState(initial: string | undefined): ExploreUrlState {
|
||||
return { datasource: null, queries: [], range: DEFAULT_RANGE };
|
||||
}
|
||||
|
||||
export function serializeStateToUrlParam(state: ExploreState, compact?: boolean): string {
|
||||
const urlState: ExploreUrlState = {
|
||||
datasource: state.initialDatasource,
|
||||
queries: state.initialQueries.map(clearQueryKeys),
|
||||
range: state.range,
|
||||
};
|
||||
export function serializeStateToUrlParam(urlState: ExploreUrlState, compact?: boolean): string {
|
||||
if (compact) {
|
||||
return JSON.stringify([urlState.range.from, urlState.range.to, urlState.datasource, ...urlState.queries]);
|
||||
}
|
||||
@ -123,7 +186,7 @@ export function generateRefId(index = 0): string {
|
||||
return `${index + 1}`;
|
||||
}
|
||||
|
||||
export function generateQueryKeys(index = 0): { refId: string; key: string } {
|
||||
export function generateEmptyQuery(index = 0): { refId: string; key: string } {
|
||||
return { refId: generateRefId(index), key: generateKey(index) };
|
||||
}
|
||||
|
||||
@ -132,20 +195,23 @@ export function generateQueryKeys(index = 0): { refId: string; key: string } {
|
||||
*/
|
||||
export function ensureQueries(queries?: DataQuery[]): DataQuery[] {
|
||||
if (queries && typeof queries === 'object' && queries.length > 0) {
|
||||
return queries.map((query, i) => ({ ...query, ...generateQueryKeys(i) }));
|
||||
return queries.map((query, i) => ({ ...query, ...generateEmptyQuery(i) }));
|
||||
}
|
||||
return [{ ...generateQueryKeys() }];
|
||||
return [{ ...generateEmptyQuery() }];
|
||||
}
|
||||
|
||||
/**
|
||||
* A target is non-empty when it has keys (with non-empty values) other than refId and key.
|
||||
*/
|
||||
export function hasNonEmptyQuery(queries: DataQuery[]): boolean {
|
||||
return queries.some(
|
||||
query =>
|
||||
Object.keys(query)
|
||||
.map(k => query[k])
|
||||
.filter(v => v).length > 2
|
||||
export function hasNonEmptyQuery<TQuery extends DataQuery = any>(queries: TQuery[]): boolean {
|
||||
return (
|
||||
queries &&
|
||||
queries.some(
|
||||
query =>
|
||||
Object.keys(query)
|
||||
.map(k => query[k])
|
||||
.filter(v => v).length > 2
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@ -180,8 +246,8 @@ export function calculateResultsFromQueryTransactions(
|
||||
};
|
||||
}
|
||||
|
||||
export function getIntervals(range: RawTimeRange, datasource: DataSourceApi, resolution: number): IntervalValues {
|
||||
if (!datasource || !resolution) {
|
||||
export function getIntervals(range: RawTimeRange, lowLimit: string, resolution: number): IntervalValues {
|
||||
if (!resolution) {
|
||||
return { interval: '1s', intervalMs: 1000 };
|
||||
}
|
||||
|
||||
@ -190,7 +256,7 @@ export function getIntervals(range: RawTimeRange, datasource: DataSourceApi, res
|
||||
to: parseDate(range.to, true),
|
||||
};
|
||||
|
||||
return kbn.calculateInterval(absoluteRange, resolution, datasource.interval);
|
||||
return kbn.calculateInterval(absoluteRange, resolution, lowLimit);
|
||||
}
|
||||
|
||||
export function makeTimeSeriesList(dataList) {
|
||||
@ -214,7 +280,11 @@ export function makeTimeSeriesList(dataList) {
|
||||
/**
|
||||
* Update the query history. Side-effect: store history in local storage
|
||||
*/
|
||||
export function updateHistory(history: HistoryItem[], datasourceId: string, queries: DataQuery[]): HistoryItem[] {
|
||||
export function updateHistory<T extends DataQuery = any>(
|
||||
history: Array<HistoryItem<T>>,
|
||||
datasourceId: string,
|
||||
queries: T[]
|
||||
): Array<HistoryItem<T>> {
|
||||
const ts = Date.now();
|
||||
queries.forEach(query => {
|
||||
history = [{ query, ts }, ...history];
|
||||
|
@ -1,4 +1,5 @@
|
||||
import { TextMatch } from 'app/types/explore';
|
||||
import xss from 'xss';
|
||||
|
||||
/**
|
||||
* Adapt findMatchesInText for react-highlight-words findChunks handler.
|
||||
@ -22,7 +23,7 @@ export function findMatchesInText(haystack: string, needle: string): TextMatch[]
|
||||
}
|
||||
const matches = [];
|
||||
const cleaned = cleanNeedle(needle);
|
||||
let regexp;
|
||||
let regexp: RegExp;
|
||||
try {
|
||||
regexp = new RegExp(`(?:${cleaned})`, 'g');
|
||||
} catch (error) {
|
||||
@ -42,3 +43,12 @@ export function findMatchesInText(haystack: string, needle: string): TextMatch[]
|
||||
});
|
||||
return matches;
|
||||
}
|
||||
|
||||
export function sanitize (unsanitizedString: string): string {
|
||||
try {
|
||||
return xss(unsanitizedString);
|
||||
} catch (error) {
|
||||
console.log('String could not be sanitized', unsanitizedString);
|
||||
return unsanitizedString;
|
||||
}
|
||||
}
|
||||
|
@ -6,7 +6,14 @@ import { getMultipleMockKeys, getMockKey } from './__mocks__/apiKeysMock';
|
||||
|
||||
const setup = (propOverrides?: object) => {
|
||||
const props: Props = {
|
||||
navModel: {} as NavModel,
|
||||
navModel: {
|
||||
main: {
|
||||
text: 'Configuration'
|
||||
},
|
||||
node: {
|
||||
text: 'Api Keys'
|
||||
}
|
||||
} as NavModel,
|
||||
apiKeys: [] as ApiKey[],
|
||||
searchQuery: '',
|
||||
hasFetched: false,
|
||||
|
@ -6,8 +6,7 @@ import { NavModel, ApiKey, NewApiKey, OrgRole } from 'app/types';
|
||||
import { getNavModel } from 'app/core/selectors/navModel';
|
||||
import { getApiKeys, getApiKeysCount } from './state/selectors';
|
||||
import { loadApiKeys, deleteApiKey, setSearchQuery, addApiKey } from './state/actions';
|
||||
import PageHeader from 'app/core/components/PageHeader/PageHeader';
|
||||
import PageLoader from 'app/core/components/PageLoader/PageLoader';
|
||||
import Page from 'app/core/components/Page/Page';
|
||||
import SlideDown from 'app/core/components/Animations/SlideDown';
|
||||
import ApiKeysAddedModal from './ApiKeysAddedModal';
|
||||
import config from 'app/core/config';
|
||||
@ -240,18 +239,17 @@ export class ApiKeysPage extends PureComponent<Props, any> {
|
||||
const { hasFetched, navModel, apiKeysCount } = this.props;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageHeader model={navModel} />
|
||||
{hasFetched ? (
|
||||
apiKeysCount > 0 ? (
|
||||
this.renderApiKeyList()
|
||||
) : (
|
||||
this.renderEmptyList()
|
||||
)
|
||||
) : (
|
||||
<PageLoader pageName="Api keys" />
|
||||
)}
|
||||
</div>
|
||||
<Page navModel={navModel}>
|
||||
<Page.Contents isLoading={!hasFetched}>
|
||||
{hasFetched && (
|
||||
apiKeysCount > 0 ? (
|
||||
this.renderApiKeyList()
|
||||
) : (
|
||||
this.renderEmptyList()
|
||||
)
|
||||
)}
|
||||
</Page.Contents>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -1,132 +1,152 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`Render should render API keys table if there are any keys 1`] = `
|
||||
<div>
|
||||
<PageHeader
|
||||
model={Object {}}
|
||||
<Page
|
||||
navModel={
|
||||
Object {
|
||||
"main": Object {
|
||||
"text": "Configuration",
|
||||
},
|
||||
"node": Object {
|
||||
"text": "Api Keys",
|
||||
},
|
||||
}
|
||||
}
|
||||
>
|
||||
<PageContents
|
||||
isLoading={true}
|
||||
/>
|
||||
<PageLoader
|
||||
pageName="Api keys"
|
||||
/>
|
||||
</div>
|
||||
</Page>
|
||||
`;
|
||||
|
||||
exports[`Render should render CTA if there are no API keys 1`] = `
|
||||
<div>
|
||||
<PageHeader
|
||||
model={Object {}}
|
||||
/>
|
||||
<div
|
||||
className="page-container page-body"
|
||||
<Page
|
||||
navModel={
|
||||
Object {
|
||||
"main": Object {
|
||||
"text": "Configuration",
|
||||
},
|
||||
"node": Object {
|
||||
"text": "Api Keys",
|
||||
},
|
||||
}
|
||||
}
|
||||
>
|
||||
<PageContents
|
||||
isLoading={false}
|
||||
>
|
||||
<EmptyListCTA
|
||||
model={
|
||||
Object {
|
||||
"buttonIcon": "fa fa-plus",
|
||||
"buttonLink": "#",
|
||||
"buttonTitle": " New API Key",
|
||||
"onClick": [Function],
|
||||
"proTip": "Remember you can provide view-only API access to other applications.",
|
||||
"proTipLink": "",
|
||||
"proTipLinkTitle": "",
|
||||
"proTipTarget": "_blank",
|
||||
"title": "You haven't added any API Keys yet.",
|
||||
}
|
||||
}
|
||||
/>
|
||||
<Component
|
||||
in={false}
|
||||
<div
|
||||
className="page-container page-body"
|
||||
>
|
||||
<div
|
||||
className="cta-form"
|
||||
<EmptyListCTA
|
||||
model={
|
||||
Object {
|
||||
"buttonIcon": "fa fa-plus",
|
||||
"buttonLink": "#",
|
||||
"buttonTitle": " New API Key",
|
||||
"onClick": [Function],
|
||||
"proTip": "Remember you can provide view-only API access to other applications.",
|
||||
"proTipLink": "",
|
||||
"proTipLinkTitle": "",
|
||||
"proTipTarget": "_blank",
|
||||
"title": "You haven't added any API Keys yet.",
|
||||
}
|
||||
}
|
||||
/>
|
||||
<Component
|
||||
in={false}
|
||||
>
|
||||
<button
|
||||
className="cta-form__close btn btn-transparent"
|
||||
onClick={[Function]}
|
||||
<div
|
||||
className="cta-form"
|
||||
>
|
||||
<i
|
||||
className="fa fa-close"
|
||||
/>
|
||||
</button>
|
||||
<h5>
|
||||
Add API Key
|
||||
</h5>
|
||||
<form
|
||||
className="gf-form-group"
|
||||
onSubmit={[Function]}
|
||||
>
|
||||
<div
|
||||
className="gf-form-inline"
|
||||
<button
|
||||
className="cta-form__close btn btn-transparent"
|
||||
onClick={[Function]}
|
||||
>
|
||||
<i
|
||||
className="fa fa-close"
|
||||
/>
|
||||
</button>
|
||||
<h5>
|
||||
Add API Key
|
||||
</h5>
|
||||
<form
|
||||
className="gf-form-group"
|
||||
onSubmit={[Function]}
|
||||
>
|
||||
<div
|
||||
className="gf-form max-width-21"
|
||||
className="gf-form-inline"
|
||||
>
|
||||
<span
|
||||
className="gf-form-label"
|
||||
<div
|
||||
className="gf-form max-width-21"
|
||||
>
|
||||
Key name
|
||||
</span>
|
||||
<input
|
||||
className="gf-form-input"
|
||||
onChange={[Function]}
|
||||
placeholder="Name"
|
||||
type="text"
|
||||
value=""
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className="gf-form"
|
||||
>
|
||||
<span
|
||||
className="gf-form-label"
|
||||
>
|
||||
Role
|
||||
</span>
|
||||
<span
|
||||
className="gf-form-select-wrapper"
|
||||
>
|
||||
<select
|
||||
className="gf-form-input gf-size-auto"
|
||||
onChange={[Function]}
|
||||
value="Viewer"
|
||||
<span
|
||||
className="gf-form-label"
|
||||
>
|
||||
<option
|
||||
key="Viewer"
|
||||
label="Viewer"
|
||||
Key name
|
||||
</span>
|
||||
<input
|
||||
className="gf-form-input"
|
||||
onChange={[Function]}
|
||||
placeholder="Name"
|
||||
type="text"
|
||||
value=""
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className="gf-form"
|
||||
>
|
||||
<span
|
||||
className="gf-form-label"
|
||||
>
|
||||
Role
|
||||
</span>
|
||||
<span
|
||||
className="gf-form-select-wrapper"
|
||||
>
|
||||
<select
|
||||
className="gf-form-input gf-size-auto"
|
||||
onChange={[Function]}
|
||||
value="Viewer"
|
||||
>
|
||||
Viewer
|
||||
</option>
|
||||
<option
|
||||
key="Editor"
|
||||
label="Editor"
|
||||
value="Editor"
|
||||
>
|
||||
Editor
|
||||
</option>
|
||||
<option
|
||||
key="Admin"
|
||||
label="Admin"
|
||||
value="Admin"
|
||||
>
|
||||
Admin
|
||||
</option>
|
||||
</select>
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className="gf-form"
|
||||
>
|
||||
<button
|
||||
className="btn gf-form-btn btn-success"
|
||||
<option
|
||||
key="Viewer"
|
||||
label="Viewer"
|
||||
value="Viewer"
|
||||
>
|
||||
Viewer
|
||||
</option>
|
||||
<option
|
||||
key="Editor"
|
||||
label="Editor"
|
||||
value="Editor"
|
||||
>
|
||||
Editor
|
||||
</option>
|
||||
<option
|
||||
key="Admin"
|
||||
label="Admin"
|
||||
value="Admin"
|
||||
>
|
||||
Admin
|
||||
</option>
|
||||
</select>
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className="gf-form"
|
||||
>
|
||||
Add
|
||||
</button>
|
||||
<button
|
||||
className="btn gf-form-btn btn-success"
|
||||
>
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</Component>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</Component>
|
||||
</div>
|
||||
</PageContents>
|
||||
</Page>
|
||||
`;
|
||||
|
@ -12,8 +12,7 @@ import { getDatasourceSrv, DatasourceSrv } from 'app/features/plugins/datasource
|
||||
import kbn from 'app/core/utils/kbn';
|
||||
|
||||
// Types
|
||||
import { DataQueryOptions, DataQueryResponse } from 'app/types';
|
||||
import { TimeRange, TimeSeries, LoadingState } from '@grafana/ui';
|
||||
import { TimeRange, TimeSeries, LoadingState, DataQueryResponse, DataQueryOptions } from '@grafana/ui/src/types';
|
||||
|
||||
const DEFAULT_PLUGIN_ERROR = 'Error in plugin';
|
||||
|
||||
|
@ -20,6 +20,7 @@ import { PanelPlugin } from 'app/types';
|
||||
import { TimeRange } from '@grafana/ui';
|
||||
|
||||
import variables from 'sass/_variables.scss';
|
||||
import templateSrv from 'app/features/templating/template_srv';
|
||||
|
||||
export interface Props {
|
||||
panel: PanelModel;
|
||||
@ -78,6 +79,10 @@ export class PanelChrome extends PureComponent<Props, State> {
|
||||
});
|
||||
};
|
||||
|
||||
onInterpolate = (value: string, format?: string) => {
|
||||
return templateSrv.replace(value, this.props.panel.scopedVars, format);
|
||||
};
|
||||
|
||||
get isVisible() {
|
||||
return !this.props.dashboard.otherPanelInFullscreen(this.props.panel);
|
||||
}
|
||||
@ -124,9 +129,10 @@ export class PanelChrome extends PureComponent<Props, State> {
|
||||
timeSeries={timeSeries}
|
||||
timeRange={timeRange}
|
||||
options={panel.getOptions(plugin.exports.PanelDefaults)}
|
||||
width={width - 2 * variables.panelHorizontalPadding }
|
||||
width={width - 2 * variables.panelHorizontalPadding}
|
||||
height={height - PANEL_HEADER_HEIGHT - variables.panelVerticalPadding}
|
||||
renderCounter={renderCounter}
|
||||
onInterpolate={this.onInterpolate}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
@ -3,6 +3,7 @@ import classNames from 'classnames';
|
||||
|
||||
import PanelHeaderCorner from './PanelHeaderCorner';
|
||||
import { PanelHeaderMenu } from './PanelHeaderMenu';
|
||||
import templateSrv from 'app/features/templating/template_srv';
|
||||
|
||||
import { DashboardModel } from 'app/features/dashboard/dashboard_model';
|
||||
import { PanelModel } from 'app/features/dashboard/panel_model';
|
||||
@ -45,7 +46,9 @@ export class PanelHeader extends Component<Props, State> {
|
||||
const isFullscreen = false;
|
||||
const isLoading = false;
|
||||
const panelHeaderClass = classNames({ 'panel-header': true, 'grid-drag-handle': !isFullscreen });
|
||||
const { panel, dashboard, timeInfo } = this.props;
|
||||
const { panel, dashboard, timeInfo, scopedVars } = this.props;
|
||||
const title = templateSrv.replaceWithText(panel.title, scopedVars);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PanelHeaderCorner
|
||||
@ -65,7 +68,7 @@ export class PanelHeader extends Component<Props, State> {
|
||||
<div className="panel-title">
|
||||
<span className="icon-gf panel-alert-icon" />
|
||||
<span className="panel-title-text">
|
||||
{panel.title} <span className="fa fa-caret-down panel-menu-toggle" />
|
||||
{title} <span className="fa fa-caret-down panel-menu-toggle" />
|
||||
</span>
|
||||
|
||||
{this.state.panelMenuOpen && (
|
||||
|
@ -1,11 +1,11 @@
|
||||
import React, { SFC } from 'react';
|
||||
import React, { FC } from 'react';
|
||||
import { PanelMenuItem } from '@grafana/ui';
|
||||
|
||||
interface Props {
|
||||
children: any;
|
||||
}
|
||||
|
||||
export const PanelHeaderMenuItem: SFC<Props & PanelMenuItem> = props => {
|
||||
export const PanelHeaderMenuItem: FC<Props & PanelMenuItem> = props => {
|
||||
const isSubMenu = props.type === 'submenu';
|
||||
const isDivider = props.type === 'divider';
|
||||
return isDivider ? (
|
||||
|
@ -15,7 +15,7 @@ interface State {
|
||||
}
|
||||
|
||||
export class PanelResizer extends PureComponent<Props, State> {
|
||||
initialHeight: number = Math.floor(document.documentElement.scrollHeight * 0.4);
|
||||
initialHeight: number = Math.floor(document.documentElement.scrollHeight * 0.3);
|
||||
prevEditorHeight: number;
|
||||
throttledChangeHeight: (height: number) => void;
|
||||
throttledResizeDone: () => void;
|
||||
|
@ -1,4 +1,4 @@
|
||||
import React, { SFC } from 'react';
|
||||
import React, { FC } from 'react';
|
||||
import { Tooltip } from '@grafana/ui';
|
||||
|
||||
interface Props {
|
||||
@ -10,7 +10,7 @@ interface Props {
|
||||
tooltipInfo?: any;
|
||||
}
|
||||
|
||||
export const DataSourceOptions: SFC<Props> = ({ label, placeholder, name, value, onChange, tooltipInfo }) => {
|
||||
export const DataSourceOptions: FC<Props> = ({ label, placeholder, name, value, onChange, tooltipInfo }) => {
|
||||
const dsOption = (
|
||||
<div className="gf-form gf-form--flex-end">
|
||||
<label className="gf-form-label">{label}</label>
|
||||
|
@ -10,6 +10,8 @@ interface Props {
|
||||
heading: string;
|
||||
renderToolbar?: () => JSX.Element;
|
||||
toolbarItems?: EditorToolbarView[];
|
||||
scrollTop?: number;
|
||||
setScrollTop?: (value: React.MouseEvent<HTMLElement>) => void;
|
||||
}
|
||||
|
||||
export interface EditorToolbarView {
|
||||
@ -103,23 +105,20 @@ export class EditorTabBody extends PureComponent<Props, State> {
|
||||
}
|
||||
|
||||
render() {
|
||||
const { children, renderToolbar, heading, toolbarItems } = this.props;
|
||||
const { children, renderToolbar, heading, toolbarItems, scrollTop, setScrollTop } = this.props;
|
||||
const { openView, fadeIn, isOpen } = this.state;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="toolbar">
|
||||
<div className="toolbar__heading">{heading}</div>
|
||||
{renderToolbar && renderToolbar()}
|
||||
{toolbarItems.length > 0 && (
|
||||
<>
|
||||
<div className="gf-form--grow" />
|
||||
{toolbarItems.map(item => this.renderButton(item))}
|
||||
</>
|
||||
)}
|
||||
<div className="toolbar__left">
|
||||
<div className="toolbar__heading">{heading}</div>
|
||||
{renderToolbar && renderToolbar()}
|
||||
</div>
|
||||
{toolbarItems.map(item => this.renderButton(item))}
|
||||
</div>
|
||||
<div className="panel-editor__scroll">
|
||||
<CustomScrollbar autoHide={false}>
|
||||
<CustomScrollbar autoHide={false} scrollTop={scrollTop} setScrollTop={setScrollTop}>
|
||||
<div className="panel-editor__content">
|
||||
<FadeIn in={isOpen} duration={200} unmountOnExit={true}>
|
||||
{openView && this.renderOpenView(openView)}
|
||||
|
@ -3,24 +3,22 @@ import React, { PureComponent } from 'react';
|
||||
import _ from 'lodash';
|
||||
|
||||
// Components
|
||||
import 'app/features/panel/metrics_tab';
|
||||
import { EditorTabBody, EditorToolbarView } from './EditorTabBody';
|
||||
import { DataSourcePicker } from 'app/core/components/Select/DataSourcePicker';
|
||||
import { QueryInspector } from './QueryInspector';
|
||||
import { QueryOptions } from './QueryOptions';
|
||||
import { AngularQueryComponentScope } from 'app/features/panel/metrics_tab';
|
||||
import { PanelOptionsGroup } from '@grafana/ui';
|
||||
import { QueryEditorRow } from './QueryEditorRow';
|
||||
|
||||
// Services
|
||||
import { getDatasourceSrv } from 'app/features/plugins/datasource_srv';
|
||||
import { BackendSrv, getBackendSrv } from 'app/core/services/backend_srv';
|
||||
import { AngularComponent, getAngularLoader } from 'app/core/services/AngularLoader';
|
||||
import config from 'app/core/config';
|
||||
|
||||
// Types
|
||||
import { PanelModel } from '../panel_model';
|
||||
import { DashboardModel } from '../dashboard_model';
|
||||
import { DataQuery, DataSourceSelectItem } from 'app/types';
|
||||
import { DataQuery, DataSourceSelectItem } from '@grafana/ui/src/types';
|
||||
import { PluginHelp } from 'app/core/components/PluginHelp/PluginHelp';
|
||||
|
||||
interface Props {
|
||||
@ -34,66 +32,27 @@ interface State {
|
||||
isLoadingHelp: boolean;
|
||||
isPickerOpen: boolean;
|
||||
isAddingMixed: boolean;
|
||||
scrollTop: number;
|
||||
}
|
||||
|
||||
export class QueriesTab extends PureComponent<Props, State> {
|
||||
element: HTMLElement;
|
||||
component: AngularComponent;
|
||||
datasources: DataSourceSelectItem[] = getDatasourceSrv().getMetricSources();
|
||||
backendSrv: BackendSrv = getBackendSrv();
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
isLoadingHelp: false,
|
||||
currentDS: this.findCurrentDataSource(),
|
||||
helpContent: null,
|
||||
isPickerOpen: false,
|
||||
isAddingMixed: false,
|
||||
};
|
||||
}
|
||||
state: State = {
|
||||
isLoadingHelp: false,
|
||||
currentDS: this.findCurrentDataSource(),
|
||||
helpContent: null,
|
||||
isPickerOpen: false,
|
||||
isAddingMixed: false,
|
||||
scrollTop: 0,
|
||||
};
|
||||
|
||||
findCurrentDataSource(): DataSourceSelectItem {
|
||||
const { panel } = this.props;
|
||||
return this.datasources.find(datasource => datasource.value === panel.datasource) || this.datasources[0];
|
||||
}
|
||||
|
||||
getAngularQueryComponentScope(): AngularQueryComponentScope {
|
||||
const { panel, dashboard } = this.props;
|
||||
|
||||
return {
|
||||
panel: panel,
|
||||
dashboard: dashboard,
|
||||
refresh: () => panel.refresh(),
|
||||
render: () => panel.render,
|
||||
addQuery: this.onAddQuery,
|
||||
moveQuery: this.onMoveQuery,
|
||||
removeQuery: this.onRemoveQuery,
|
||||
events: panel.events,
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
if (!this.element) {
|
||||
return;
|
||||
}
|
||||
|
||||
const loader = getAngularLoader();
|
||||
const template = '<metrics-tab />';
|
||||
const scopeProps = {
|
||||
ctrl: this.getAngularQueryComponentScope(),
|
||||
};
|
||||
|
||||
this.component = loader.load(this.element, scopeProps, template);
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
if (this.component) {
|
||||
this.component.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
onChangeDataSource = datasource => {
|
||||
const { panel } = this.props;
|
||||
const { currentDS } = this.state;
|
||||
@ -137,7 +96,7 @@ export class QueriesTab extends PureComponent<Props, State> {
|
||||
|
||||
onAddQuery = (query?: Partial<DataQuery>) => {
|
||||
this.props.panel.addQuery(query);
|
||||
this.forceUpdate();
|
||||
this.setState({ scrollTop: this.state.scrollTop + 100000 });
|
||||
};
|
||||
|
||||
onAddQueryClick = () => {
|
||||
@ -146,9 +105,7 @@ export class QueriesTab extends PureComponent<Props, State> {
|
||||
return;
|
||||
}
|
||||
|
||||
this.props.panel.addQuery();
|
||||
this.component.digest();
|
||||
this.forceUpdate();
|
||||
this.onAddQuery();
|
||||
};
|
||||
|
||||
onRemoveQuery = (query: DataQuery) => {
|
||||
@ -171,9 +128,20 @@ export class QueriesTab extends PureComponent<Props, State> {
|
||||
};
|
||||
|
||||
renderToolbar = () => {
|
||||
const { currentDS } = this.state;
|
||||
const { currentDS, isAddingMixed } = this.state;
|
||||
|
||||
return <DataSourcePicker datasources={this.datasources} onChange={this.onChangeDataSource} current={currentDS} />;
|
||||
return (
|
||||
<>
|
||||
<DataSourcePicker datasources={this.datasources} onChange={this.onChangeDataSource} current={currentDS} />
|
||||
<div className="flex-grow" />
|
||||
{!isAddingMixed && (
|
||||
<button className="btn navbar-button navbar-button--primary" onClick={this.onAddQueryClick}>
|
||||
Add Query
|
||||
</button>
|
||||
)}
|
||||
{isAddingMixed && this.renderMixedPicker()}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
renderMixedPicker = () => {
|
||||
@ -190,17 +158,21 @@ export class QueriesTab extends PureComponent<Props, State> {
|
||||
|
||||
onAddMixedQuery = datasource => {
|
||||
this.onAddQuery({ datasource: datasource.name });
|
||||
this.component.digest();
|
||||
this.setState({ isAddingMixed: false });
|
||||
this.setState({ isAddingMixed: false, scrollTop: this.state.scrollTop + 10000 });
|
||||
};
|
||||
|
||||
onMixedPickerBlur = () => {
|
||||
this.setState({ isAddingMixed: false });
|
||||
};
|
||||
|
||||
setScrollTop = (event: React.MouseEvent<HTMLElement>) => {
|
||||
const target = event.target as HTMLElement;
|
||||
this.setState({ scrollTop: target.scrollTop });
|
||||
};
|
||||
|
||||
render() {
|
||||
const { panel } = this.props;
|
||||
const { currentDS, isAddingMixed } = this.state;
|
||||
const { currentDS, scrollTop } = this.state;
|
||||
|
||||
const queryInspector: EditorToolbarView = {
|
||||
title: 'Query Inspector',
|
||||
@ -214,32 +186,28 @@ export class QueriesTab extends PureComponent<Props, State> {
|
||||
};
|
||||
|
||||
return (
|
||||
<EditorTabBody heading="Queries" renderToolbar={this.renderToolbar} toolbarItems={[queryInspector, dsHelp]}>
|
||||
<EditorTabBody
|
||||
heading="Queries to"
|
||||
renderToolbar={this.renderToolbar}
|
||||
toolbarItems={[queryInspector, dsHelp]}
|
||||
setScrollTop={this.setScrollTop}
|
||||
scrollTop={scrollTop}
|
||||
>
|
||||
<>
|
||||
<PanelOptionsGroup>
|
||||
<div className="query-editor-rows">
|
||||
<div ref={element => (this.element = element)} />
|
||||
|
||||
<div className="gf-form-query">
|
||||
<div className="gf-form gf-form-query-letter-cell">
|
||||
<label className="gf-form-label">
|
||||
<span className="gf-form-query-letter-cell-carret muted">
|
||||
<i className="fa fa-caret-down" />
|
||||
</span>{' '}
|
||||
<span className="gf-form-query-letter-cell-letter">{panel.getNextQueryLetter()}</span>
|
||||
</label>
|
||||
</div>
|
||||
<div className="gf-form">
|
||||
{!isAddingMixed && (
|
||||
<button className="btn btn-secondary gf-form-btn" onClick={this.onAddQueryClick}>
|
||||
Add Query
|
||||
</button>
|
||||
)}
|
||||
{isAddingMixed && this.renderMixedPicker()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PanelOptionsGroup>
|
||||
<div className="query-editor-rows">
|
||||
{panel.targets.map((query, index) => (
|
||||
<QueryEditorRow
|
||||
dataSourceValue={query.datasource || panel.datasource}
|
||||
key={query.refId}
|
||||
panel={panel}
|
||||
query={query}
|
||||
onRemoveQuery={this.onRemoveQuery}
|
||||
onAddQuery={this.onAddQuery}
|
||||
onMoveQuery={this.onMoveQuery}
|
||||
inMixedMode={currentDS.meta.mixed}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<PanelOptionsGroup>
|
||||
<QueryOptions panel={panel} datasource={currentDS} />
|
||||
</PanelOptionsGroup>
|
||||
|
256
public/app/features/dashboard/panel_editor/QueryEditorRow.tsx
Normal file
256
public/app/features/dashboard/panel_editor/QueryEditorRow.tsx
Normal file
@ -0,0 +1,256 @@
|
||||
// Libraries
|
||||
import React, { PureComponent } from 'react';
|
||||
import classNames from 'classnames';
|
||||
import _ from 'lodash';
|
||||
|
||||
// Utils & Services
|
||||
import { getDatasourceSrv } from 'app/features/plugins/datasource_srv';
|
||||
import { AngularComponent, getAngularLoader } from 'app/core/services/AngularLoader';
|
||||
import { Emitter } from 'app/core/utils/emitter';
|
||||
|
||||
// Types
|
||||
import { PanelModel } from '../panel_model';
|
||||
import { DataQuery, DataSourceApi } from '@grafana/ui';
|
||||
|
||||
interface Props {
|
||||
panel: PanelModel;
|
||||
query: DataQuery;
|
||||
onAddQuery: (query?: DataQuery) => void;
|
||||
onRemoveQuery: (query: DataQuery) => void;
|
||||
onMoveQuery: (query: DataQuery, direction: number) => void;
|
||||
dataSourceValue: string | null;
|
||||
inMixedMode: boolean;
|
||||
}
|
||||
|
||||
interface State {
|
||||
loadedDataSourceValue: string | null | undefined;
|
||||
datasource: DataSourceApi | null;
|
||||
isCollapsed: boolean;
|
||||
angularScope: AngularQueryComponentScope | null;
|
||||
}
|
||||
|
||||
export class QueryEditorRow extends PureComponent<Props, State> {
|
||||
element: HTMLElement | null = null;
|
||||
angularQueryEditor: AngularComponent | null = null;
|
||||
|
||||
state: State = {
|
||||
datasource: null,
|
||||
isCollapsed: false,
|
||||
angularScope: null,
|
||||
loadedDataSourceValue: undefined,
|
||||
};
|
||||
|
||||
componentDidMount() {
|
||||
this.loadDatasource();
|
||||
}
|
||||
|
||||
getAngularQueryComponentScope(): AngularQueryComponentScope {
|
||||
const { panel, query } = this.props;
|
||||
const { datasource } = this.state;
|
||||
|
||||
return {
|
||||
datasource: datasource,
|
||||
target: query,
|
||||
panel: panel,
|
||||
refresh: () => panel.refresh(),
|
||||
render: () => panel.render(),
|
||||
events: panel.events,
|
||||
};
|
||||
}
|
||||
|
||||
async loadDatasource() {
|
||||
const { query, panel } = this.props;
|
||||
const dataSourceSrv = getDatasourceSrv();
|
||||
const datasource = await dataSourceSrv.get(query.datasource || panel.datasource);
|
||||
|
||||
this.setState({ datasource, loadedDataSourceValue: this.props.dataSourceValue });
|
||||
}
|
||||
|
||||
componentDidUpdate() {
|
||||
const { loadedDataSourceValue } = this.state;
|
||||
|
||||
// check if we need to load another datasource
|
||||
if (loadedDataSourceValue !== this.props.dataSourceValue) {
|
||||
if (this.angularQueryEditor) {
|
||||
this.angularQueryEditor.destroy();
|
||||
this.angularQueryEditor = null;
|
||||
}
|
||||
this.loadDatasource();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.element || this.angularQueryEditor) {
|
||||
return;
|
||||
}
|
||||
|
||||
const loader = getAngularLoader();
|
||||
const template = '<plugin-component type="query-ctrl" />';
|
||||
const scopeProps = { ctrl: this.getAngularQueryComponentScope() };
|
||||
|
||||
this.angularQueryEditor = loader.load(this.element, scopeProps, template);
|
||||
|
||||
// give angular time to compile
|
||||
setTimeout(() => {
|
||||
this.setState({ angularScope: scopeProps.ctrl });
|
||||
}, 10);
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
if (this.angularQueryEditor) {
|
||||
this.angularQueryEditor.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
onToggleCollapse = () => {
|
||||
this.setState({ isCollapsed: !this.state.isCollapsed });
|
||||
};
|
||||
|
||||
onQueryChange = (query: DataQuery) => {
|
||||
Object.assign(this.props.query, query);
|
||||
this.onExecuteQuery();
|
||||
};
|
||||
|
||||
onExecuteQuery = () => {
|
||||
this.props.panel.refresh();
|
||||
};
|
||||
|
||||
renderPluginEditor() {
|
||||
const { query } = this.props;
|
||||
const { datasource } = this.state;
|
||||
|
||||
if (datasource.pluginExports.QueryCtrl) {
|
||||
return <div ref={element => (this.element = element)} />;
|
||||
}
|
||||
|
||||
if (datasource.pluginExports.QueryEditor) {
|
||||
const QueryEditor = datasource.pluginExports.QueryEditor;
|
||||
return (
|
||||
<QueryEditor
|
||||
query={query}
|
||||
datasource={datasource}
|
||||
onQueryChange={this.onQueryChange}
|
||||
onExecuteQuery={this.onExecuteQuery}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return <div>Data source plugin does not export any Query Editor component</div>;
|
||||
}
|
||||
|
||||
onToggleEditMode = () => {
|
||||
const { angularScope } = this.state;
|
||||
|
||||
if (angularScope && angularScope.toggleEditorMode) {
|
||||
angularScope.toggleEditorMode();
|
||||
this.angularQueryEditor.digest();
|
||||
}
|
||||
|
||||
if (this.state.isCollapsed) {
|
||||
this.setState({ isCollapsed: false });
|
||||
}
|
||||
};
|
||||
|
||||
get hasTextEditMode() {
|
||||
const { angularScope } = this.state;
|
||||
return angularScope && angularScope.toggleEditorMode;
|
||||
}
|
||||
|
||||
onRemoveQuery = () => {
|
||||
this.props.onRemoveQuery(this.props.query);
|
||||
};
|
||||
|
||||
onCopyQuery = () => {
|
||||
const copy = _.cloneDeep(this.props.query);
|
||||
this.props.onAddQuery(copy);
|
||||
};
|
||||
|
||||
onDisableQuery = () => {
|
||||
this.props.query.hide = !this.props.query.hide;
|
||||
this.forceUpdate();
|
||||
};
|
||||
|
||||
renderCollapsedText(): string | null {
|
||||
const { angularScope } = this.state;
|
||||
|
||||
if (angularScope && angularScope.getCollapsedText) {
|
||||
return angularScope.getCollapsedText();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
render() {
|
||||
const { query, inMixedMode } = this.props;
|
||||
const { datasource, isCollapsed } = this.state;
|
||||
const isDisabled = query.hide;
|
||||
|
||||
const bodyClasses = classNames('query-editor-row__body gf-form-query', {
|
||||
'query-editor-row__body--collapsed': isCollapsed,
|
||||
});
|
||||
|
||||
const rowClasses = classNames('query-editor-row', {
|
||||
'query-editor-row--disabled': isDisabled,
|
||||
'gf-form-disabled': isDisabled,
|
||||
});
|
||||
|
||||
if (!datasource) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={rowClasses}>
|
||||
<div className="query-editor-row__header">
|
||||
<div className="query-editor-row__ref-id" onClick={this.onToggleCollapse}>
|
||||
{isCollapsed && <i className="fa fa-caret-right" />}
|
||||
{!isCollapsed && <i className="fa fa-caret-down" />}
|
||||
<span>{query.refId}</span>
|
||||
{inMixedMode && <em className="query-editor-row__context-info"> ({datasource.name})</em>}
|
||||
{isDisabled && <em className="query-editor-row__context-info"> Disabled</em>}
|
||||
</div>
|
||||
<div className="query-editor-row__collapsed-text" onClick={this.onToggleEditMode}>
|
||||
{isCollapsed && <div>{this.renderCollapsedText()}</div>}
|
||||
</div>
|
||||
<div className="query-editor-row__actions">
|
||||
{this.hasTextEditMode && (
|
||||
<button
|
||||
className="query-editor-row__action"
|
||||
onClick={this.onToggleEditMode}
|
||||
title="Toggle text edit mode"
|
||||
>
|
||||
<i className="fa fa-fw fa-pencil" />
|
||||
</button>
|
||||
)}
|
||||
<button className="query-editor-row__action" onClick={() => this.props.onMoveQuery(query, 1)}>
|
||||
<i className="fa fa-fw fa-arrow-down" />
|
||||
</button>
|
||||
<button className="query-editor-row__action" onClick={() => this.props.onMoveQuery(query, -1)}>
|
||||
<i className="fa fa-fw fa-arrow-up" />
|
||||
</button>
|
||||
<button className="query-editor-row__action" onClick={this.onCopyQuery} title="Duplicate query">
|
||||
<i className="fa fa-fw fa-copy" />
|
||||
</button>
|
||||
<button className="query-editor-row__action" onClick={this.onDisableQuery} title="Disable/enable query">
|
||||
{isDisabled && <i className="fa fa-fw fa-eye-slash" />}
|
||||
{!isDisabled && <i className="fa fa-fw fa-eye" />}
|
||||
</button>
|
||||
<button className="query-editor-row__action" onClick={this.onRemoveQuery} title="Remove query">
|
||||
<i className="fa fa-fw fa-trash" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className={bodyClasses}>{this.renderPluginEditor()}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export interface AngularQueryComponentScope {
|
||||
target: DataQuery;
|
||||
panel: PanelModel;
|
||||
events: Emitter;
|
||||
refresh: () => void;
|
||||
render: () => void;
|
||||
datasource: DataSourceApi;
|
||||
toggleEditorMode?: () => void;
|
||||
getCollapsedText?: () => string;
|
||||
}
|
@ -177,7 +177,6 @@ export class QueryInspector extends PureComponent<Props, State> {
|
||||
|
||||
render() {
|
||||
const { response, isLoading } = this.state.dsQuery;
|
||||
const { isMocking } = this.state;
|
||||
const openNodes = this.getNrOfOpenNodes();
|
||||
|
||||
if (isLoading) {
|
||||
@ -199,20 +198,7 @@ export class QueryInspector extends PureComponent<Props, State> {
|
||||
</CopyToClipboard>
|
||||
</div>
|
||||
|
||||
{!isMocking && <JSONFormatter json={response} open={openNodes} onDidRender={this.setFormattedJson} />}
|
||||
{isMocking && (
|
||||
<div className="query-troubleshooter__body">
|
||||
<div className="gf-form p-l-1 gf-form--v-stretch">
|
||||
<textarea
|
||||
className="gf-form-input"
|
||||
style={{ width: '95%' }}
|
||||
rows={10}
|
||||
onInput={this.setMockedResponse}
|
||||
placeholder="JSON"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<JSONFormatter json={response} open={openNodes} onDidRender={this.setFormattedJson} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
@ -10,11 +10,12 @@ import { Input } from 'app/core/components/Form';
|
||||
import { EventsWithValidation } from 'app/core/components/Form/Input';
|
||||
import { InputStatus } from 'app/core/components/Form/Input';
|
||||
import DataSourceOption from './DataSourceOption';
|
||||
import { GfFormLabel } from '@grafana/ui';
|
||||
import { FormLabel } from '@grafana/ui';
|
||||
|
||||
// Types
|
||||
import { PanelModel } from '../panel_model';
|
||||
import { ValidationEvents, DataSourceSelectItem } from 'app/types';
|
||||
import { DataSourceSelectItem } from '@grafana/ui/src/types';
|
||||
import { ValidationEvents } from 'app/types';
|
||||
|
||||
const timeRangeValidationEvents: ValidationEvents = {
|
||||
[EventsWithValidation.onBlur]: [
|
||||
@ -164,7 +165,7 @@ export class QueryOptions extends PureComponent<Props, State> {
|
||||
{this.renderOptions()}
|
||||
|
||||
<div className="gf-form">
|
||||
<GfFormLabel>Relative time</GfFormLabel>
|
||||
<FormLabel>Relative time</FormLabel>
|
||||
<Input
|
||||
type="text"
|
||||
className="width-6"
|
||||
|
@ -26,6 +26,7 @@ interface Props {
|
||||
interface State {
|
||||
isVizPickerOpen: boolean;
|
||||
searchQuery: string;
|
||||
scrollTop: number;
|
||||
}
|
||||
|
||||
export class VisualizationTab extends PureComponent<Props, State> {
|
||||
@ -39,6 +40,7 @@ export class VisualizationTab extends PureComponent<Props, State> {
|
||||
this.state = {
|
||||
isVizPickerOpen: false,
|
||||
searchQuery: '',
|
||||
scrollTop: 0,
|
||||
};
|
||||
}
|
||||
|
||||
@ -143,7 +145,7 @@ export class VisualizationTab extends PureComponent<Props, State> {
|
||||
};
|
||||
|
||||
onOpenVizPicker = () => {
|
||||
this.setState({ isVizPickerOpen: true });
|
||||
this.setState({ isVizPickerOpen: true, scrollTop: 0 });
|
||||
};
|
||||
|
||||
onCloseVizPicker = () => {
|
||||
@ -201,9 +203,14 @@ export class VisualizationTab extends PureComponent<Props, State> {
|
||||
|
||||
renderHelp = () => <PluginHelp plugin={this.props.plugin} type="help" />;
|
||||
|
||||
setScrollTop = (event: React.MouseEvent<HTMLElement>) => {
|
||||
const target = event.target as HTMLElement;
|
||||
this.setState({ scrollTop: target.scrollTop });
|
||||
};
|
||||
|
||||
render() {
|
||||
const { plugin } = this.props;
|
||||
const { isVizPickerOpen, searchQuery } = this.state;
|
||||
const { isVizPickerOpen, searchQuery, scrollTop } = this.state;
|
||||
|
||||
const pluginHelp: EditorToolbarView = {
|
||||
heading: 'Help',
|
||||
@ -212,7 +219,8 @@ export class VisualizationTab extends PureComponent<Props, State> {
|
||||
};
|
||||
|
||||
return (
|
||||
<EditorTabBody heading="Visualization" renderToolbar={this.renderToolbar} toolbarItems={[pluginHelp]}>
|
||||
<EditorTabBody heading="Visualization" renderToolbar={this.renderToolbar} toolbarItems={[pluginHelp]}
|
||||
scrollTop={scrollTop} setScrollTop={this.setScrollTop}>
|
||||
<>
|
||||
<FadeIn in={isVizPickerOpen} duration={200} unmountOnExit={true}>
|
||||
<VizTypePicker
|
||||
|
@ -1,7 +1,10 @@
|
||||
import { Emitter } from 'app/core/utils/emitter';
|
||||
// Libraries
|
||||
import _ from 'lodash';
|
||||
|
||||
// Types
|
||||
import { Emitter } from 'app/core/utils/emitter';
|
||||
import { PANEL_OPTIONS_KEY_PREFIX } from 'app/core/constants';
|
||||
import { DataQuery } from 'app/types';
|
||||
import { DataQuery } from '@grafana/ui/src/types';
|
||||
|
||||
export interface GridPos {
|
||||
x: number;
|
||||
@ -52,7 +55,6 @@ const mustKeepProps: { [str: string]: boolean } = {
|
||||
hasRefreshed: true,
|
||||
events: true,
|
||||
cacheTimeout: true,
|
||||
nullPointMode: true,
|
||||
cachedPluginOptions: true,
|
||||
transparent: true,
|
||||
};
|
||||
@ -60,7 +62,7 @@ const mustKeepProps: { [str: string]: boolean } = {
|
||||
const defaults: any = {
|
||||
gridPos: { x: 0, y: 0, h: 3, w: 6 },
|
||||
datasource: null,
|
||||
targets: [{}],
|
||||
targets: [{ refId: 'A' }],
|
||||
cachedPluginOptions: {},
|
||||
transparent: false,
|
||||
};
|
||||
@ -81,7 +83,7 @@ export class PanelModel {
|
||||
collapsed?: boolean;
|
||||
panels?: any;
|
||||
soloMode?: boolean;
|
||||
targets: any[];
|
||||
targets: DataQuery[];
|
||||
datasource: string;
|
||||
thresholds?: any;
|
||||
|
||||
@ -116,6 +118,18 @@ export class PanelModel {
|
||||
|
||||
// defaults
|
||||
_.defaultsDeep(this, _.cloneDeep(defaults));
|
||||
// queries must have refId
|
||||
this.ensureQueryIds();
|
||||
}
|
||||
|
||||
ensureQueryIds() {
|
||||
if (this.targets) {
|
||||
for (const query of this.targets) {
|
||||
if (!query.refId) {
|
||||
query.refId = this.getNextQueryLetter();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getOptions(panelDefaults) {
|
||||
@ -241,9 +255,7 @@ export class PanelModel {
|
||||
addQuery(query?: Partial<DataQuery>) {
|
||||
query = query || { refId: 'A' };
|
||||
query.refId = this.getNextQueryLetter();
|
||||
query.isNew = true;
|
||||
|
||||
this.targets.push(query);
|
||||
this.targets.push(query as DataQuery);
|
||||
}
|
||||
|
||||
getNextQueryLetter(): string {
|
||||
|
@ -9,6 +9,10 @@ describe('PanelModel', () => {
|
||||
model = new PanelModel({
|
||||
type: 'table',
|
||||
showColumns: true,
|
||||
targets: [
|
||||
{refId: 'A'},
|
||||
{noRefId: true}
|
||||
]
|
||||
});
|
||||
});
|
||||
|
||||
@ -20,6 +24,10 @@ describe('PanelModel', () => {
|
||||
expect(model.showColumns).toBe(true);
|
||||
});
|
||||
|
||||
it('should add missing refIds', () => {
|
||||
expect(model.targets[1].refId).toBe('B');
|
||||
});
|
||||
|
||||
it('getSaveModel should remove defaults', () => {
|
||||
const saveModel = model.getSaveModel();
|
||||
expect(saveModel.gridPos).toBe(undefined);
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user