Compare commits

...

77 Commits

Author SHA1 Message Date
Torkel Ödegaard
3f4c2e7957 Fixed issue with alert links in alert list panel causing panel not found errors, fixes #15680
(cherry picked from commit 91fc73bf82)
2019-03-19 14:06:06 +01:00
Torkel Ödegaard
b7ad897c46 Improved error handling when rendering dashboard panels, fixes #15913
(cherry picked from commit 2333cf3fd1)
2019-03-19 14:06:06 +01:00
Marcus Efraimsson
74c89ecbb9 fix allow anonymous server bind for ldap search
(cherry picked from commit c242d38301)
2019-03-19 14:06:06 +01:00
Marcus Efraimsson
3a206c5493 add nil/length check when delete old login attempts
(cherry picked from commit e3b3062107)
2019-03-19 14:06:06 +01:00
Marcus Efraimsson
3ed4d91aee fix discord notifier so it doesn't crash when there are no image generated
(cherry picked from commit f21c976b27)
2019-03-19 14:06:06 +01:00
Marcus Efraimsson
a5c4dff3b3 fix only users that can edit a dashboard should be able to update panel json
(cherry picked from commit 1a588dadbe)
2019-03-19 14:06:06 +01:00
Peter Holmberg
444a8ecc36 move to new component to handle focus
(cherry picked from commit 98ff39173f)
2019-03-19 14:06:06 +01:00
Peter Holmberg
14666f00f8 added state to not set focus on search every render
(cherry picked from commit 52c2b7606d)
2019-03-19 14:06:06 +01:00
Dominik Prokop
145b1aa0f6 Snapshots update
(cherry picked from commit 9c51912827)
2019-03-19 14:06:06 +01:00
Dominik Prokop
b22d269e9c Use app config directly in ButtonRow instead of passing datasources page URL via prop
(cherry picked from commit 12a868a999)
2019-03-19 14:06:06 +01:00
Dominik Prokop
9c6a72a0c3 Update snapshots
(cherry picked from commit 7fb3cbd72f)
2019-03-19 14:06:06 +01:00
Dominik Prokop
1b667055dc Fixed url of back button in datasource edit page, when root_url configured
(cherry picked from commit d50a7ef6ac)
2019-03-19 14:06:06 +01:00
Hugo Häggmark
1bc6bbfc17 release: Bumped version 2019-03-19 14:06:06 +01:00
Hugo Häggmark
0c44a04ba8 Merge pull request #15824 from grafana/cp-6.0.1
Cherry picks for v6.0.1
2019-03-06 15:21:49 +01:00
Torkel Ödegaard
ae4bdf9403 Bumped version to 6.0.1 2019-03-06 14:32:38 +01:00
Torkel Ödegaard
3d4f08bea5 Temp fix for scrollbar issue PR that was tricky to cherry pick (#15713) 2019-03-06 14:31:05 +01:00
Marcus Efraimsson
97a193d7a5 log phantomjs output even if it timeout and include orgId when render alert
(cherry picked from commit 36f3accf0d)
2019-03-06 14:27:31 +01:00
Torkel Ödegaard
177bee85c6 Fixed image rendering issue for dashboards with auto refresh, casued by missing reloadOnSearch flag on route, fixes #15631
(cherry picked from commit 70f1abbe37)
2019-03-06 14:26:06 +01:00
Marcus Efraimsson
ef3531312c fix allow anonymous initial bind for ldap search
(cherry picked from commit 3b9f0e6ef2)
2019-03-06 14:25:31 +01:00
Jon Ferreira
24da153147 Expose onQueryChange to angular plugins
(cherry picked from commit a3da8dc673)
2019-03-06 14:24:33 +01:00
Torkel Ödegaard
816e81ac0a Fixed scrollbar not visible due to content being added a bit after mount, fixes #15711
(cherry picked from commit cd78f0bef2)
2019-03-06 14:24:16 +01:00
Hugo Häggmark
20d7d4b8c3 fix: update datasource in componentDidUpdate
Closes #15751

(cherry picked from commit 09b036dc93)
2019-03-06 14:22:06 +01:00
Torkel Ödegaard
3e243adc29 Fixed scrolling issue that caused scroll to be locked to the bottom of a long dashboard, fixes #15712
(cherry picked from commit e6a83bf0e1)
2019-03-06 14:21:23 +01:00
Johannes Schill
94e21de199 Viewers with viewers_can_edit should be able to access /explore (#15787)
* fix: Viewers with viewers_can_edit should be able to access /explore #15773

* refactoring initial PR a bit to simplify function and reduce duplication

(cherry picked from commit a81d5486b0)
2019-03-06 14:18:22 +01:00
Daniel Lee
eff00e8cc4 utils: show string errors. Fixes #15782
(cherry picked from commit 8b1e25b50a)
2019-03-06 14:14:27 +01:00
Hugo Häggmark
78d614ab9c Made sure that DataSourceOption displays value and fires onChange/onBlur events (#15757)
* Fixed #15682 

* fix: Add hideTimeOverride to state since we need to control the Switch

* fix: Back the maxDataPoints change, we need to keep it as a string

Co-authored-by:johannes.schill@polyester.se
(cherry picked from commit 48570c6272)
2019-03-06 14:13:37 +01:00
Marcus Efraimsson
073186bd3e org admins should only be able to access org admin pages
(cherry picked from commit 5638c67be8)
2019-03-06 14:12:49 +01:00
Marcus Efraimsson
9044b269a3 only editor/admin should have access to alert list/notifications pages
(cherry picked from commit a29b99b96b)
2019-03-06 14:12:41 +01:00
Johannes Schill
18ac0824bd fix: When in tv-mode, autofitpanel should not take space from the navbar #15650
(cherry picked from commit cc40a515be)
2019-03-06 14:10:12 +01:00
Johannes Schill
967089d179 fix: Kiosk mode should have &kiosk appended to the url #15765
(cherry picked from commit 92ec8757d3)
2019-03-06 14:09:38 +01:00
Jon Ferreira
1dadcb0a5b Toggle stack should trigger a render, not a refresh
(cherry picked from commit 0bdca7957a)
2019-03-06 14:08:07 +01:00
Johannes Schill
e610e4ca41 fix: Return url when query dashboards by tag
(cherry picked from commit 8d5ccc7831)
2019-03-06 14:07:36 +01:00
Peter Holmberg
c946a1fe2f fix
(cherry picked from commit a9ca8e9dec)
2019-03-06 14:06:41 +01:00
Leonard Gram
1a48d82133 service: fix for disabled internal metrics.
Update of the internal metrics for Grafana was
disabled by mistake when refactoring the code.

Fixes #15651

(cherry picked from commit 36788183d8)
2019-03-06 14:05:00 +01:00
Daniel Lee
acedf16a8c stackdriver: fix for float64 bounds for distribution metrics
Adds support for explicit distribution metrics and float64 bounds

Fixes #14509

(cherry picked from commit d1e249a803)
2019-03-06 13:59:30 +01:00
Daniel Lee
3344c53cd5 stackdriver: change reducer mapping for distribution metrics
- Distribution metrics are now mapped to more reducers
when the metric kind is cumulative.
- The witdth of the metrics dropdown is now much wider.
- Changed the text from Select Aggregation to Select Reducer
to line up with the UI in Stackdriver.

(cherry picked from commit 35fc0c5329)
2019-03-06 13:58:28 +01:00
Hugo Häggmark
5854eddb3d Fixed bug with getting teams for user
(cherry picked from commit dafcfd70a7)
2019-03-06 13:55:38 +01:00
Leonard Gram
34a9a621b6 Merge pull request #15567 from grafana/cp-6.0-stable
Cherry picks for 6.0 stable
2019-02-25 15:47:26 +01:00
Leonard Gram
0655a23091 release 6.0.0 2019-02-25 15:24:39 +01:00
Daniel Lee
0f63051a3b graph: fixes click after scroll in series override menu
Makes changes to dropdown-typeahead2 so that a css
class for the button can be passed in. Means it can
be used instead of dropdown-typeahead.

Switches to using dropdown-typeahead2 for series_overrides
directive and for the influxdb, mysql and postgres datasources
as it already contains a fix for this issue.

This commit also fixes the index property which
was set using an incorrectly spelled length property in the
series_overrides directive.

Closes #15621

(cherry picked from commit e76655df43)
2019-02-25 12:50:23 +01:00
Torkel Ödegaard
4a2852d0ed Fixed value dropdown not updating when it's current value updates, fixes #15566
(cherry picked from commit f768808b6e)
2019-02-25 12:39:09 +01:00
Dominik Prokop
682baf8d46 Bring back plugins page styles
(cherry picked from commit f471be2453)
2019-02-25 12:37:26 +01:00
Torkel Ödegaard
9c2adc115c Minor fix/polish to gauge panel and threshold editor
(cherry picked from commit b4627ec302)
2019-02-25 10:38:23 +01:00
Daniel Lee
f93cd857cb panel: defensive coding that fixes #15563
If a plugin incorrectly uses an attribute in the
query-editor-row directive, it should not throw
an exception.

(cherry picked from commit bd0f55cbb8)
2019-02-25 10:23:28 +01:00
Johannes Schill
28d5737221 fix: Filter out values not supported by Explore yet #15281
(cherry picked from commit 6f9edf4a22)
2019-02-25 10:03:40 +01:00
Torkel Ödegaard
d85a4c3dd0 Fixed problem with prettier formatting after cherry picks 2019-02-21 15:24:09 +01:00
Daniel Lee
115215d317 Pass dashboardModel to PanelCtrl class. Fixes #15541
(cherry picked from commit c5a70e9b97)
2019-02-21 12:49:50 +01:00
Marcus Efraimsson
99fecdcf46 fix: mysql query using __interval_ms variable throws error
fixes #14507

(cherry picked from commit d8e655bbcf)
2019-02-21 12:49:31 +01:00
Dominik Prokop
9bfcfe271f Fix build
(cherry picked from commit f28cc871e1)
2019-02-21 12:49:10 +01:00
Dominik Prokop
251596aed4 Display graphite function name editor in a tooltip
(cherry picked from commit 1069d7f5b1)
2019-02-21 12:48:40 +01:00
Dominik Prokop
daa6819e0c Bump Prettier version (#15532)
* Fix prettier version to 1.16.4

(cherry picked from commit 88a46e6dd4)
2019-02-21 12:44:45 +01:00
Leonard Gram
19192fc0b5 Merge pull request #15529 from grafana/cp-6.0.0-beta3
Cherry picks for v6.0.0-beta3
2019-02-19 11:32:29 +01:00
Leonard Gram
57977c9db6 release 6.0.0-beta3 2019-02-19 11:00:15 +01:00
Hugo Häggmark
82cf6b951a Fixes #15505
(cherry picked from commit 7f04848dea)
2019-02-19 09:48:00 +01:00
Torkel Ödegaard
56f75e9e9d Fixed navbar backbutton padding
(cherry picked from commit 13e27601df)
2019-02-19 09:47:24 +01:00
Peter Holmberg
a32d5ed16a Fixing array direction, adding simple render test, fixes #15478
Fixed unit test and updated gauge
Added migration for threshold order

(cherry picked from commit 93e8edfddd)
2019-02-19 09:46:47 +01:00
Torkel Ödegaard
84bd280c7a Updated explore icon and style tweaks
Lowered icon size and improved paddings, tried to align placement between dashboard and explore

(cherry picked from commit df170aee16)
2019-02-19 09:46:05 +01:00
Daniel Lee
1ce7ef7ae0 cli: chmod 755 for backend plugin binaries
Fixes #15500. Does a simple filename check if the binary names ends
with _linux_amd64 or _darwin_amd64 then sets the file mode to 755.

(cherry picked from commit 56c965e5df)
2019-02-19 09:45:11 +01:00
Hugo Häggmark
9d5529c453 Fixes #15477
(cherry picked from commit 92972eed7b)
2019-02-19 09:44:22 +01:00
Dominik Prokop
0fd59e72b0 Remove maxDataPoints and interval props from props to remember in panel model
(cherry picked from commit 8e035676e1)
2019-02-19 09:43:42 +01:00
Dominik Prokop
6fc3c6a7ed Fix typo in view mode cykle button
(cherry picked from commit 75dd7d0036)
2019-02-19 09:42:56 +01:00
Torkel Ödegaard
5091e06e4c Removed primary class from Add Query button, and changed name of Panel Options tab o General Options
(cherry picked from commit bf826d7c81)
2019-02-19 09:42:19 +01:00
Torkel Ödegaard
b229b16259 Fixed issue with PanelHeader and grid-drag-handle class still being applied in fullscreen, fixes #15480
(cherry picked from commit 89ad525986)
2019-02-19 09:40:53 +01:00
Torkel Ödegaard
d45fc0cadd Fixed prettier issue (#15471)
Fixed prettier CI issue that caused build failures

(cherry picked from commit 2d5fd7fdfd)
2019-02-19 09:22:36 +01:00
Torkel Ödegaard
3a36c750dd Added missing strict type checking options to grafana/ui and fixed type errors
(cherry picked from commit d6de40cbe6)
2019-02-19 09:20:23 +01:00
Torkel Ödegaard
6da26aa3cd Changed how react panels store their options (#15468)
Changed how react panels store their options

* Added a ReactPanelPlugin as the interface that react panels export, this way react panels have clearer api, and gives us hooks to handle migrations and a way for panel to handle panel changes in the future
* Moved gauge value options into a sub oject and made editor more generic, will be moved out of gauge pane later and shared between singlestat, gauge, bargauge, honecomb
* Also remove nested options prop that was there due to bug
* Added missing Gauge props
* Fixed gauge issue that will require migration later and also value options editor did not handle null decimals or 0 decimals
* Fixed unit tests
* More fixes for react panels

(cherry picked from commit abddb442a1)
2019-02-18 17:45:47 +01:00
Peter Holmberg
652ea3c08e Move error boundry from DataPanel to PanelChrome #15424, rebased and squashed PR
hard move
Revert "hard move"
This reverts commit a2dad6157a.
bubble error from datapanel to panelchrome
remove comments
implement show error in panelcorner
fixed issue with updatePopperPosition
using error callback from datapanel instead
Fixes to error handling and clearing, also publishing of legacy events so old query editors work with react panels fully
added another error message scenario
Restored loading spinner to DataPanel
Fixed merge issues

(cherry picked from commit 125c087aab64b434dac03e577b39574fe841fed7)
2019-02-18 17:44:52 +01:00
Torkel Ödegaard
acb329f2f1 fixed handling of alert urls with true flags, fixes #15454
(cherry picked from commit 7699706e65)
2019-02-18 16:55:57 +01:00
Torkel Ödegaard
d757c74442 Fixed dashboard navbar buttons being visible in fullscreen, fixes #15450
(cherry picked from commit 7b59b9cf4b)
2019-02-18 16:55:16 +01:00
Maxim Neverov
688a8e286d Extracted common code for diff calculation
(cherry picked from commit 28eaac3a9c)
2019-02-18 16:51:21 +01:00
Maxim Neverov
d924d3f6c9 Fix percent_diff calculation when points are nulls
(cherry picked from commit 3c2f6094b2)
2019-02-18 16:51:07 +01:00
Torkel Ödegaard
754be5a66e Fixed issue with sass variables used from typescript, the prettier lowercases export variables
(cherry picked from commit ef9e74fabb)
2019-02-18 16:47:49 +01:00
z0029c4
9e457077c6 added support for influxdb non_negative_difference function in tsdb
(cherry picked from commit 13974cdd28)
2019-02-18 16:39:48 +01:00
z0029c4
c3e5a2c968 added support for influxdb non_negative_difference function in tsdb for alerting
(cherry picked from commit 8cb1e5b918)
2019-02-18 16:39:20 +01:00
Brian Gann
dcb3a344d2 allow 90 percent of alertTimeout for rendering to complete vs 50 percent
(cherry picked from commit c98b00c302)
2019-02-18 16:35:29 +01:00
Torkel Ödegaard
1a2b5cd3cc Merge branch 'master' into v6.0.x 2019-02-13 12:44:31 +01:00
Torkel Ödegaard
6a71b199ca Updated version to beta2 2019-02-11 16:55:36 +01:00
204 changed files with 2848 additions and 1375 deletions

View File

@@ -5,7 +5,7 @@
"company": "Grafana Labs"
},
"name": "grafana",
"version": "6.0.0-pre3",
"version": "6.0.2",
"repository": {
"type": "git",
"url": "http://github.com/grafana/grafana.git"
@@ -83,7 +83,7 @@
"postcss-browser-reporter": "^0.5.0",
"postcss-loader": "^2.0.6",
"postcss-reporter": "^5.0.0",
"prettier": "1.9.2",
"prettier": "1.16.4",
"react-hot-loader": "^4.3.6",
"react-test-renderer": "^16.5.0",
"redux-mock-store": "^1.5.3",
@@ -121,7 +121,7 @@
"jest": "jest --notify --watch",
"api-tests": "jest --notify --watch --config=tests/api/jest.js",
"storybook": "cd packages/grafana-ui && yarn storybook",
"prettier:check": "prettier -- --list-different \"**/*.{ts,tsx,scss}\""
"prettier:check": "prettier --list-different \"**/*.{ts,tsx,scss}\""
},
"husky": {
"hooks": {

View File

@@ -1,6 +1,6 @@
import React, { Component, createRef } from 'react';
import PopperController from '../Tooltip/PopperController';
import Popper from '../Tooltip/Popper';
import { PopperController } from '../Tooltip/PopperController';
import { Popper } from '../Tooltip/Popper';
import { ColorPickerPopover } from './ColorPickerPopover';
import { Themeable } from '../../types';
import { getColorFromHexRgbOrName } from '../../utils/namedColorsPalette';

View File

@@ -4,7 +4,7 @@ import { getColorName, getColorFromHexRgbOrName } from '../../utils/namedColorsP
import { ColorPickerProps, warnAboutColorPickerPropsDeprecation } from './ColorPicker';
import { PopperContentProps } from '../Tooltip/PopperController';
import SpectrumPalette from './SpectrumPalette';
import { GrafanaThemeType } from '@grafana/ui';
import { GrafanaThemeType } from '../../types/theme';
export interface Props<T> extends ColorPickerProps, PopperContentProps {
customPickers?: T;

View File

@@ -14,6 +14,7 @@ interface Props {
scrollTop?: number;
setScrollTop: (event: any) => void;
autoHeightMin?: number | string;
updateAfterMountMs?: number;
}
/**
@@ -42,16 +43,26 @@ export class CustomScrollbar extends PureComponent<Props> {
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();
// this logic is to make scrollbar visible when content is added body after mount
if (this.props.updateAfterMountMs) {
setTimeout(() => this.updateAfterMount(), this.props.updateAfterMountMs);
}
}
updateAfterMount() {
if (this.ref && this.ref.current) {
const scrollbar = this.ref.current as any;
if (scrollbar.update) {
scrollbar.update();
}
}
}
componentDidUpdate() {

View File

@@ -1,4 +1,4 @@
.custom-scrollbars {
.custom-scrollbars {
// Fix for Firefox. For some reason sometimes .view container gets a height of its content, but in order to
// make scroll working it should fit outer container size (scroll appears only when inner container size is
// greater than outer one).
@@ -14,7 +14,7 @@
.track-vertical {
border-radius: 3px;
width: 6px !important;
right: 2px;
right: 0px;
bottom: 2px;
top: 2px;
}

View File

@@ -1,6 +1,6 @@
import React, { FunctionComponent } from 'react';
import { storiesOf } from '@storybook/react';
import { DeleteButton } from '@grafana/ui';
import { DeleteButton } from './DeleteButton';
const CenteredStory: FunctionComponent<{}> = ({ children }) => {
return (

View File

@@ -0,0 +1,8 @@
.empty-search-result {
border-left: 3px solid $info-box-border-color;
background-color: $empty-list-cta-bg;
padding: $spacer;
min-width: 350px;
border-radius: $border-radius;
margin-bottom: $spacer * 2;
}

View File

@@ -84,9 +84,9 @@ describe('Get thresholds formatted', () => {
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' },
{ index: 1, value: 50, color: '#EAB839' },
{ index: 2, value: 75, color: '#6ED0E0' },
],
});

View File

@@ -9,7 +9,7 @@ import { Themeable } from '../../index';
type TimeSeriesValue = string | number | null;
export interface Props extends Themeable {
decimals: number;
decimals?: number | null;
height: number;
valueMappings: ValueMapping[];
maxValue: number;
@@ -98,16 +98,15 @@ export class Gauge extends PureComponent<Props> {
getFormattedThresholds() {
const { maxValue, minValue, thresholds, theme } = this.props;
const thresholdsSortedByIndex = [...thresholds].sort((t1, t2) => t1.index - t2.index);
const lastThreshold = thresholdsSortedByIndex[thresholdsSortedByIndex.length - 1];
const lastThreshold = thresholds[thresholds.length - 1];
return [
...thresholdsSortedByIndex.map(threshold => {
...thresholds.map(threshold => {
if (threshold.index === 0) {
return { value: minValue, color: getColorFromHexRgbOrName(threshold.color, theme.type) };
}
const previousThreshold = thresholdsSortedByIndex[threshold.index - 1];
const previousThreshold = thresholds[threshold.index - 1];
return { value: threshold.value, color: getColorFromHexRgbOrName(previousThreshold.color, theme.type) };
}),
{ value: maxValue, color: getColorFromHexRgbOrName(lastThreshold.color, theme.type) },
@@ -116,9 +115,9 @@ export class Gauge extends PureComponent<Props> {
getFontScale(length: number): number {
if (length > 12) {
return FONT_SCALE - length * 5 / 120;
return FONT_SCALE - (length * 5) / 110;
}
return FONT_SCALE - length * 5 / 105;
return FONT_SCALE - (length * 5) / 100;
}
draw() {

View File

@@ -22,7 +22,7 @@ export class Graph extends PureComponent<GraphProps> {
showBars: false,
};
element: HTMLElement | null;
element: HTMLElement | null = null;
componentDidUpdate() {
this.draw();

View File

@@ -1,9 +1,8 @@
import React, { ChangeEvent } from 'react';
import { shallow } from 'enzyme';
import { mount } from 'enzyme';
import { ThresholdsEditor, Props } from './ThresholdsEditor';
const setup = (propOverrides?: object) => {
const setup = (propOverrides?: Partial<Props>) => {
const props: Props = {
onChange: jest.fn(),
thresholds: [],
@@ -11,12 +10,26 @@ const setup = (propOverrides?: object) => {
Object.assign(props, propOverrides);
return shallow(<ThresholdsEditor {...props} />).instance() as ThresholdsEditor;
const wrapper = mount(<ThresholdsEditor {...props} />);
const instance = wrapper.instance() as ThresholdsEditor;
return {
instance,
wrapper,
};
};
describe('Render', () => {
it('should render with base threshold', () => {
const { wrapper } = setup();
expect(wrapper).toMatchSnapshot();
});
});
describe('Initialization', () => {
it('should add a base threshold if missing', () => {
const instance = setup();
const { instance } = setup();
expect(instance.state.thresholds).toEqual([{ index: 0, value: -Infinity, color: '#7EB26D' }]);
});
@@ -24,7 +37,7 @@ describe('Initialization', () => {
describe('Add threshold', () => {
it('should not add threshold at index 0', () => {
const instance = setup();
const { instance } = setup();
instance.onAddThreshold(0);
@@ -32,32 +45,32 @@ describe('Add threshold', () => {
});
it('should add threshold', () => {
const instance = setup();
const { instance } = setup();
instance.onAddThreshold(1);
expect(instance.state.thresholds).toEqual([
{ index: 1, value: 50, color: '#EAB839' },
{ index: 0, value: -Infinity, color: '#7EB26D' },
{ index: 1, value: 50, color: '#EAB839' },
]);
});
it('should add another threshold above a first', () => {
const instance = setup({
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' },
{ index: 1, value: 50, color: '#EAB839' },
{ index: 2, value: 75, color: '#6ED0E0' },
]);
});
it('should add another threshold between first and second index', () => {
const instance = setup({
const { instance } = setup({
thresholds: [
{ index: 0, value: -Infinity, color: '#7EB26D' },
{ index: 1, value: 50, color: '#EAB839' },
@@ -68,10 +81,10 @@ describe('Add threshold', () => {
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' },
{ index: 1, value: 50, color: '#EAB839' },
{ index: 2, value: 62.5, color: '#EF843C' },
{ index: 3, value: 75, color: '#6ED0E0' },
]);
});
});
@@ -83,7 +96,7 @@ describe('Remove threshold', () => {
{ index: 1, value: 50, color: '#EAB839' },
{ index: 2, value: 75, color: '#6ED0E0' },
];
const instance = setup({ thresholds });
const { instance } = setup({ thresholds });
instance.onRemoveThreshold(thresholds[0]);
@@ -96,9 +109,7 @@ describe('Remove threshold', () => {
{ index: 1, value: 50, color: '#EAB839' },
{ index: 2, value: 75, color: '#6ED0E0' },
];
const instance = setup({
thresholds,
});
const { instance } = setup({ thresholds });
instance.onRemoveThreshold(thresholds[1]);
@@ -116,7 +127,7 @@ describe('change threshold value', () => {
{ index: 1, value: 50, color: '#EAB839' },
{ index: 2, value: 75, color: '#6ED0E0' },
];
const instance = setup({ thresholds });
const { instance } = setup({ thresholds });
const mockEvent = ({ target: { value: '12' } } as any) as ChangeEvent<HTMLInputElement>;
@@ -126,7 +137,7 @@ describe('change threshold value', () => {
});
it('should update value', () => {
const instance = setup();
const { instance } = setup();
const thresholds = [
{ index: 0, value: -Infinity, color: '#7EB26D' },
{ index: 1, value: 50, color: '#EAB839' },
@@ -150,24 +161,24 @@ describe('change threshold value', () => {
});
describe('on blur threshold value', () => {
it('should resort rows and update indexes', () => {
const instance = setup();
it.only('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 = {
instance.setState({
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' },
{ index: 1, value: 75, color: '#6ED0E0' },
{ index: 2, value: 78, color: '#EAB839' },
]);
});
});

View File

@@ -1,9 +1,10 @@
import React, { PureComponent, ChangeEvent } from 'react';
import { Threshold } from '../../types';
import { ColorPicker } from '../ColorPicker/ColorPicker';
import { PanelOptionsGroup } from '../PanelOptionsGroup/PanelOptionsGroup';
import { ColorPicker } from '..';
import { PanelOptionsGroup } from '..';
import { colors } from '../../utils';
import { getColorFromHexRgbOrName, ThemeContext } from '@grafana/ui';
import { ThemeContext } from '../../themes/ThemeContext';
import { getColorFromHexRgbOrName } from '../../utils/namedColorsPalette';
export interface Props {
thresholds: Threshold[];
@@ -54,16 +55,16 @@ export class ThresholdsEditor extends PureComponent<Props, State> {
const value = afterThresholdValue - (afterThresholdValue - beforeThresholdValue) / 2;
// Set a color
const color = colors.filter(c => newThresholds.some(t => t.color === c) === false)[0];
const color = colors.filter(c => !newThresholds.some(t => t.color === c))[1];
this.setState(
{
thresholds: this.sortThresholds([
...newThresholds,
{
color,
index,
value: value as number,
color,
},
]),
},
@@ -137,10 +138,11 @@ export class ThresholdsEditor extends PureComponent<Props, State> {
onBlur = () => {
this.setState(prevState => {
const sortThresholds = this.sortThresholds([...prevState.thresholds]);
let index = sortThresholds.length - 1;
let index = 0;
sortThresholds.forEach(t => {
t.index = index--;
t.index = index++;
});
return { thresholds: sortThresholds };
});
@@ -153,12 +155,11 @@ export class ThresholdsEditor extends PureComponent<Props, State> {
sortThresholds = (thresholds: Threshold[]) => {
return thresholds.sort((t1, t2) => {
return t2.value - t1.value;
return t1.value - t2.value;
});
};
renderInput = (threshold: Threshold) => {
const value = threshold.index === 0 ? 'Base' : threshold.value;
return (
<div className="thresholds-row-input-inner">
<span className="thresholds-row-input-inner-arrow" />
@@ -169,20 +170,27 @@ export class ThresholdsEditor extends PureComponent<Props, State> {
</div>
)}
</div>
{threshold.index === 0 && (
<div className="thresholds-row-input-inner-value">
<input type="text" value="Base" readOnly />
</div>
)}
{threshold.index > 0 && (
<>
<div className="thresholds-row-input-inner-value">
<input
type="number"
step="0.0001"
onChange={event => this.onChangeThresholdValue(event, threshold)}
value={value}
value={threshold.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>
);
@@ -190,14 +198,16 @@ export class ThresholdsEditor extends PureComponent<Props, State> {
render() {
const { thresholds } = this.state;
return (
<ThemeContext.Consumer>
{theme => {
return (
<PanelOptionsGroup title="Thresholds">
<div className="thresholds">
{thresholds.map((threshold, index) => {
{thresholds
.slice(0)
.reverse()
.map((threshold, index) => {
return (
<div className="thresholds-row" key={`${threshold.index}-${index}`}>
<div

View File

@@ -43,7 +43,7 @@
}
.thresholds-row-input {
margin-top: 49px;
margin-top: 44px;
margin-left: 2px;
}

View File

@@ -0,0 +1,7 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Render should render with base threshold 1`] = `
<ContextConsumer>
<Component />
</ContextConsumer>
`;

View File

@@ -1,7 +1,7 @@
import React, { PureComponent } from 'react';
import * as PopperJS from 'popper.js';
import { Manager, Popper as ReactPopper, PopperArrowProps } from 'react-popper';
import { Portal } from '@grafana/ui';
import { Portal } from '../Portal/Portal';
import Transition from 'react-transition-group/Transition';
import { PopperContent } from './PopperController';
@@ -17,12 +17,7 @@ const transitionStyles: { [key: string]: object } = {
exiting: { opacity: 0, transitionDelay: '500ms' },
};
export type RenderPopperArrowFn = (
props: {
arrowProps: PopperArrowProps;
placement: string;
}
) => JSX.Element;
export type RenderPopperArrowFn = (props: { arrowProps: PopperArrowProps; placement: string }) => JSX.Element;
interface Props extends React.HTMLAttributes<HTMLDivElement> {
show: boolean;
@@ -35,8 +30,16 @@ interface Props extends React.HTMLAttributes<HTMLDivElement> {
class Popper extends PureComponent<Props> {
render() {
const { show, placement, onMouseEnter, onMouseLeave, className, wrapperClassName, renderArrow } = this.props;
const { content } = this.props;
const {
content,
show,
placement,
onMouseEnter,
onMouseLeave,
className,
wrapperClassName,
renderArrow,
} = this.props;
return (
<Manager>
@@ -65,9 +68,10 @@ class Popper extends PureComponent<Props> {
className={`${wrapperClassName}`}
>
<div className={className}>
{typeof content === 'string'
? content
: React.cloneElement(content, {
{typeof content === 'string' && content}
{React.isValidElement(content) && React.cloneElement(content)}
{typeof content === 'function' &&
content({
updatePopperPosition: scheduleUpdate,
})}
{renderArrow &&
@@ -89,4 +93,4 @@ class Popper extends PureComponent<Props> {
}
}
export default Popper;
export { Popper };

View File

@@ -7,7 +7,7 @@ export interface PopperContentProps {
updatePopperPosition?: () => void;
}
export type PopperContent<T extends PopperContentProps> = string | React.ReactElement<T>;
export type PopperContent<T extends PopperContentProps> = string | React.ReactElement<T> | ((props: T) => JSX.Element);
export interface UsingPopperProps {
show?: boolean;
@@ -101,4 +101,4 @@ class PopperController extends React.Component<Props, State> {
}
}
export default PopperController;
export { PopperController };

View File

@@ -1,7 +1,7 @@
import React, { createRef } from 'react';
import * as PopperJS from 'popper.js';
import Popper from './Popper';
import PopperController, { UsingPopperProps } from './PopperController';
import { Popper } from './Popper';
import { PopperController, UsingPopperProps } from './PopperController';
interface TooltipProps extends UsingPopperProps {
theme?: 'info' | 'error';

View File

@@ -1,5 +1,7 @@
export { DeleteButton } from './DeleteButton/DeleteButton';
export { Tooltip } from './Tooltip/Tooltip';
export { PopperController } from './Tooltip/PopperController';
export { Popper } from './Tooltip/Popper';
export { Portal } from './Portal/Portal';
export { CustomScrollbar } from './CustomScrollbar/CustomScrollbar';

View File

@@ -6,7 +6,7 @@ let themeMock: ((name?: string) => GrafanaTheme) | null;
export let getTheme = (name?: string) => (themeMock && themeMock(name)) || (name === 'light' ? lightTheme : darkTheme);
export const mockTheme = (mock: (name: string) => GrafanaTheme) => {
export const mockTheme = (mock: (name?: string) => GrafanaTheme) => {
themeMock = mock;
return () => {
themeMock = null;

View File

@@ -29,6 +29,16 @@ export interface DataQuery {
datasource?: string | null;
}
export interface DataQueryError {
data?: {
message?: string;
error?: string;
};
message?: string;
status?: string;
statusText?: string;
}
export interface DataQueryOptions<TQuery extends DataQuery = DataQuery> {
timezone: string;
range: TimeRange;

View File

@@ -1,3 +1,4 @@
import { ComponentClass } from 'react';
import { TimeSeries, LoadingState, TableData } from './data';
import { TimeRange } from './time';
@@ -19,11 +20,29 @@ export interface PanelData {
tableData?: TableData;
}
export interface PanelOptionsProps<T = any> {
export interface PanelEditorProps<T = any> {
options: T;
onChange: (options: T) => void;
}
export class ReactPanelPlugin<TOptions = any> {
panel: ComponentClass<PanelProps<TOptions>>;
editor?: ComponentClass<PanelEditorProps<TOptions>>;
defaults?: TOptions;
constructor(panel: ComponentClass<PanelProps<TOptions>>) {
this.panel = panel;
}
setEditor(editor: ComponentClass<PanelEditorProps<TOptions>>) {
this.editor = editor;
}
setDefaults(defaults: TOptions) {
this.defaults = defaults;
}
}
export interface PanelSize {
width: number;
height: number;

View File

@@ -1,5 +1,5 @@
import { ComponentClass } from 'react';
import { PanelProps, PanelOptionsProps } from './panel';
import { ReactPanelPlugin } from './panel';
import { DataQueryOptions, DataQuery, DataQueryResponse, QueryHint, QueryFixAction } from './datasource';
export interface DataSourceApi<TQuery extends DataQuery = DataQuery> {
@@ -81,9 +81,7 @@ export interface PluginExports {
// Panel plugin
PanelCtrl?: any;
Panel?: ComponentClass<PanelProps>;
PanelOptions?: ComponentClass<PanelOptionsProps>;
PanelDefaults?: any;
reactPanel: ReactPanelPlugin;
}
export interface PluginMeta {

View File

@@ -1,20 +1,20 @@
import { toFixed } from './valueFormats';
import { toFixed, DecimalCount } from './valueFormats';
export function toPercent(size: number, decimals: number) {
export function toPercent(size: number, decimals: DecimalCount) {
if (size === null) {
return '';
}
return toFixed(size, decimals) + '%';
}
export function toPercentUnit(size: number, decimals: number) {
export function toPercentUnit(size: number, decimals: DecimalCount) {
if (size === null) {
return '';
}
return toFixed(100 * size, decimals) + '%';
}
export function toHex0x(value: number, decimals: number) {
export function toHex0x(value: number, decimals: DecimalCount) {
if (value == null) {
return '';
}
@@ -25,7 +25,7 @@ export function toHex0x(value: number, decimals: number) {
return '0x' + hexString;
}
export function toHex(value: number, decimals: number) {
export function toHex(value: number, decimals: DecimalCount) {
if (value == null) {
return '';
}
@@ -34,9 +34,9 @@ export function toHex(value: number, decimals: number) {
.toUpperCase();
}
export function sci(value: number, decimals: number) {
export function sci(value: number, decimals: DecimalCount) {
if (value == null) {
return '';
}
return value.toExponential(decimals);
return value.toExponential(decimals as number);
}

View File

@@ -125,7 +125,7 @@ export const getCategories = (): ValueFormatCategory[] => [
{
name: 'Data (Metric)',
formats: [
{ name: 'bits', id: 'decbits', fn: decimalSIPrefix('d') },
{ name: 'bits', id: 'decbits', fn: decimalSIPrefix('b') },
{ name: 'bytes', id: 'decbytes', fn: decimalSIPrefix('B') },
{ name: 'kilobytes', id: 'deckbytes', fn: decimalSIPrefix('B', 1) },
{ name: 'megabytes', id: 'decmbytes', fn: decimalSIPrefix('B', 2) },

View File

@@ -1,4 +1,4 @@
import { toFixed, toFixedScaled } from './valueFormats';
import { toFixed, toFixedScaled, DecimalCount } from './valueFormats';
import moment from 'moment';
interface IntervalsInSeconds {
@@ -27,7 +27,7 @@ const INTERVALS_IN_SECONDS: IntervalsInSeconds = {
[Interval.Millisecond]: 0.001,
};
export function toNanoSeconds(size: number, decimals: number, scaledDecimals: number) {
export function toNanoSeconds(size: number, decimals?: DecimalCount, scaledDecimals?: DecimalCount) {
if (size === null) {
return '';
}
@@ -45,7 +45,7 @@ export function toNanoSeconds(size: number, decimals: number, scaledDecimals: nu
}
}
export function toMicroSeconds(size: number, decimals: number, scaledDecimals: number) {
export function toMicroSeconds(size: number, decimals?: DecimalCount, scaledDecimals?: DecimalCount) {
if (size === null) {
return '';
}
@@ -59,7 +59,7 @@ export function toMicroSeconds(size: number, decimals: number, scaledDecimals: n
}
}
export function toMilliSeconds(size: number, decimals: number, scaledDecimals: number) {
export function toMilliSeconds(size: number, decimals?: DecimalCount, scaledDecimals?: DecimalCount) {
if (size === null) {
return '';
}
@@ -83,22 +83,29 @@ export function toMilliSeconds(size: number, decimals: number, scaledDecimals: n
return toFixedScaled(size / 31536000000, decimals, scaledDecimals, 10, ' year');
}
export function toSeconds(size: number, decimals: number, scaledDecimals: number) {
export function trySubstract(value1: DecimalCount, value2: DecimalCount): DecimalCount {
if (value1 !== null && value1 !== undefined && value2 !== null && value2 !== undefined) {
return value1 - value2;
}
return undefined;
}
export function toSeconds(size: number, decimals?: DecimalCount, scaledDecimals?: DecimalCount) {
if (size === null) {
return '';
}
// Less than 1 µs, divide in ns
if (Math.abs(size) < 0.000001) {
return toFixedScaled(size * 1e9, decimals, scaledDecimals - decimals, -9, ' ns');
return toFixedScaled(size * 1e9, decimals, trySubstract(scaledDecimals, decimals), -9, ' ns');
}
// Less than 1 ms, divide in µs
if (Math.abs(size) < 0.001) {
return toFixedScaled(size * 1e6, decimals, scaledDecimals - decimals, -6, ' µs');
return toFixedScaled(size * 1e6, decimals, trySubstract(scaledDecimals, decimals), -6, ' µs');
}
// Less than 1 second, divide in ms
if (Math.abs(size) < 1) {
return toFixedScaled(size * 1e3, decimals, scaledDecimals - decimals, -3, ' ms');
return toFixedScaled(size * 1e3, decimals, trySubstract(scaledDecimals, decimals), -3, ' ms');
}
if (Math.abs(size) < 60) {
@@ -120,7 +127,7 @@ export function toSeconds(size: number, decimals: number, scaledDecimals: number
return toFixedScaled(size / 3.15569e7, decimals, scaledDecimals, 7, ' year');
}
export function toMinutes(size: number, decimals: number, scaledDecimals: number) {
export function toMinutes(size: number, decimals?: DecimalCount, scaledDecimals?: DecimalCount) {
if (size === null) {
return '';
}
@@ -138,7 +145,7 @@ export function toMinutes(size: number, decimals: number, scaledDecimals: number
}
}
export function toHours(size: number, decimals: number, scaledDecimals: number) {
export function toHours(size: number, decimals?: DecimalCount, scaledDecimals?: DecimalCount) {
if (size === null) {
return '';
}
@@ -154,7 +161,7 @@ export function toHours(size: number, decimals: number, scaledDecimals: number)
}
}
export function toDays(size: number, decimals: number, scaledDecimals: number) {
export function toDays(size: number, decimals?: DecimalCount, scaledDecimals?: DecimalCount) {
if (size === null) {
return '';
}
@@ -168,13 +175,15 @@ export function toDays(size: number, decimals: number, scaledDecimals: number) {
}
}
export function toDuration(size: number, decimals: number, timeScale: Interval): string {
export function toDuration(size: number, decimals: DecimalCount, timeScale: Interval): string {
if (size === null) {
return '';
}
if (size === 0) {
return '0 ' + timeScale + 's';
}
if (size < 0) {
return toDuration(-size, decimals, timeScale) + ' ago';
}
@@ -189,14 +198,22 @@ export function toDuration(size: number, decimals: number, timeScale: Interval):
{ long: Interval.Second },
{ long: Interval.Millisecond },
];
// convert $size to milliseconds
// intervals_in_seconds uses seconds (duh), convert them to milliseconds here to minimize floating point errors
size *= INTERVALS_IN_SECONDS[timeScale] * 1000;
const strings = [];
// after first value >= 1 print only $decimals more
let decrementDecimals = false;
for (let i = 0; i < units.length && decimals >= 0; i++) {
let decimalsCount = 0;
if (decimals !== null || decimals !== undefined) {
decimalsCount = decimals as number;
}
for (let i = 0; i < units.length && decimalsCount >= 0; i++) {
const interval = INTERVALS_IN_SECONDS[units[i].long] * 1000;
const value = size / interval;
if (value >= 1 || decrementDecimals) {
@@ -205,14 +222,14 @@ export function toDuration(size: number, decimals: number, timeScale: Interval):
const unit = units[i].long + (floor !== 1 ? 's' : '');
strings.push(floor + ' ' + unit);
size = size % interval;
decimals--;
decimalsCount--;
}
}
return strings.join(', ');
}
export function toClock(size: number, decimals?: number) {
export function toClock(size: number, decimals?: DecimalCount) {
if (size === null) {
return '';
}
@@ -257,11 +274,11 @@ export function toClock(size: number, decimals?: number) {
return format ? `${hours}:${moment.utc(size).format(format)}` : hours;
}
export function toDurationInMilliseconds(size: number, decimals: number) {
export function toDurationInMilliseconds(size: number, decimals: DecimalCount) {
return toDuration(size, decimals, Interval.Millisecond);
}
export function toDurationInSeconds(size: number, decimals: number) {
export function toDurationInSeconds(size: number, decimals: DecimalCount) {
return toDuration(size, decimals, Interval.Second);
}
@@ -276,19 +293,19 @@ export function toDurationInHoursMinutesSeconds(size: number) {
return strings.join(':');
}
export function toTimeTicks(size: number, decimals: number, scaledDecimals: number) {
export function toTimeTicks(size: number, decimals: DecimalCount, scaledDecimals: DecimalCount) {
return toSeconds(size, decimals, scaledDecimals);
}
export function toClockMilliseconds(size: number, decimals: number) {
export function toClockMilliseconds(size: number, decimals: DecimalCount) {
return toClock(size, decimals);
}
export function toClockSeconds(size: number, decimals: number) {
export function toClockSeconds(size: number, decimals: DecimalCount) {
return toClock(size * 1000, decimals);
}
export function dateTimeAsIso(value: number, decimals: number, scaledDecimals: number, isUtc: boolean) {
export function dateTimeAsIso(value: number, decimals: DecimalCount, scaledDecimals: DecimalCount, isUtc?: boolean) {
const time = isUtc ? moment.utc(value) : moment(value);
if (moment().isSame(value, 'day')) {
@@ -297,7 +314,7 @@ export function dateTimeAsIso(value: number, decimals: number, scaledDecimals: n
return time.format('YYYY-MM-DD HH:mm:ss');
}
export function dateTimeAsUS(value: number, decimals: number, scaledDecimals: number, isUtc: boolean) {
export function dateTimeAsUS(value: number, decimals: DecimalCount, scaledDecimals: DecimalCount, isUtc?: boolean) {
const time = isUtc ? moment.utc(value) : moment(value);
if (moment().isSame(value, 'day')) {
@@ -306,7 +323,7 @@ export function dateTimeAsUS(value: number, decimals: number, scaledDecimals: nu
return time.format('MM/DD/YYYY h:mm:ss a');
}
export function dateTimeFromNow(value: number, decimals: number, scaledDecimals: number, isUtc: boolean) {
export function dateTimeFromNow(value: number, decimals: DecimalCount, scaledDecimals: DecimalCount, isUtc?: boolean) {
const time = isUtc ? moment.utc(value) : moment(value);
return time.fromNow();
}

View File

@@ -1,9 +1,9 @@
import { scaledUnits } from './valueFormats';
import { scaledUnits, DecimalCount } from './valueFormats';
export function currency(symbol: string) {
const units = ['', 'K', 'M', 'B', 'T'];
const scaler = scaledUnits(1000, units);
return (size: number, decimals: number, scaledDecimals: number) => {
return (size: number, decimals?: DecimalCount, scaledDecimals?: DecimalCount) => {
if (size === null) {
return '';
}

View File

@@ -1,8 +1,15 @@
import { getCategories } from './categories';
type ValueFormatter = (value: number, decimals?: number, scaledDecimals?: number, isUtc?: boolean) => string;
export type DecimalCount = number | null | undefined;
interface ValueFormat {
export type ValueFormatter = (
value: number,
decimals?: DecimalCount,
scaledDecimals?: DecimalCount,
isUtc?: boolean
) => string;
export interface ValueFormat {
name: string;
id: string;
fn: ValueFormatter;
@@ -22,7 +29,7 @@ let categories: ValueFormatCategory[] = [];
const index: ValueFormatterIndex = {};
let hasBuiltIndex = false;
export function toFixed(value: number, decimals?: number): string {
export function toFixed(value: number, decimals?: DecimalCount): string {
if (value === null) {
return '';
}
@@ -50,20 +57,24 @@ export function toFixed(value: number, decimals?: number): string {
export function toFixedScaled(
value: number,
decimals: number,
scaledDecimals: number,
additionalDecimals: number,
ext: string
decimals?: DecimalCount,
scaledDecimals?: DecimalCount,
additionalDecimals?: DecimalCount,
ext?: string
) {
if (scaledDecimals === null) {
return toFixed(value, decimals) + ext;
} else {
if (scaledDecimals) {
if (additionalDecimals) {
return toFixed(value, scaledDecimals + additionalDecimals) + ext;
} else {
return toFixed(value, scaledDecimals) + ext;
}
}
export function toFixedUnit(unit: string) {
return (size: number, decimals: number) => {
return toFixed(value, decimals) + ext;
}
export function toFixedUnit(unit: string): ValueFormatter {
return (size: number, decimals?: DecimalCount) => {
if (size === null) {
return '';
}
@@ -75,7 +86,7 @@ export function toFixedUnit(unit: string) {
// numeric factor. Repeatedly scales the value down by the factor until it is
// less than the factor in magnitude, or the end of the array is reached.
export function scaledUnits(factor: number, extArray: string[]) {
return (size: number, decimals: number, scaledDecimals: number) => {
return (size: number, decimals?: DecimalCount, scaledDecimals?: DecimalCount) => {
if (size === null) {
return '';
}
@@ -92,7 +103,7 @@ export function scaledUnits(factor: number, extArray: string[]) {
}
}
if (steps > 0 && scaledDecimals !== null) {
if (steps > 0 && scaledDecimals !== null && scaledDecimals !== undefined) {
decimals = scaledDecimals + 3 * steps;
}
@@ -100,17 +111,17 @@ export function scaledUnits(factor: number, extArray: string[]) {
};
}
export function locale(value: number, decimals: number) {
export function locale(value: number, decimals: DecimalCount) {
if (value == null) {
return '';
}
return value.toLocaleString(undefined, { maximumFractionDigits: decimals });
return value.toLocaleString(undefined, { maximumFractionDigits: decimals as number });
}
export function simpleCountUnit(symbol: string) {
const units = ['', 'K', 'M', 'B', 'T'];
const scaler = scaledUnits(1000, units);
return (size: number, decimals: number, scaledDecimals: number) => {
return (size: number, decimals?: DecimalCount, scaledDecimals?: DecimalCount) => {
if (size === null) {
return '';
}

View File

@@ -1,21 +1,17 @@
{
"extends": "../../tsconfig.json",
"include": [
"src/**/*.ts",
"src/**/*.tsx"
],
"exclude": [
"dist",
"node_modules"
],
"include": ["src/**/*.ts", "src/**/*.tsx"],
"exclude": ["dist", "node_modules"],
"compilerOptions": {
"rootDirs": [".", "stories"],
"module": "esnext",
"outDir": "dist",
"declaration": true,
"strict": true,
"alwaysStrict": true,
"noImplicitAny": true,
"strictNullChecks": true,
"typeRoots": ["./node_modules/@types", "types"],
"skipLibCheck": true // Temp workaround for Duplicate identifier tsc errors
},
}
}

View File

@@ -33,17 +33,17 @@ func (hs *HTTPServer) registerRoutes() {
r.Get("/profile/", reqSignedIn, hs.Index)
r.Get("/profile/password", reqSignedIn, hs.Index)
r.Get("/profile/switch-org/:id", reqSignedIn, hs.ChangeActiveOrgAndRedirectToHome)
r.Get("/org/", reqSignedIn, hs.Index)
r.Get("/org/new", reqSignedIn, hs.Index)
r.Get("/datasources/", reqSignedIn, hs.Index)
r.Get("/datasources/new", reqSignedIn, hs.Index)
r.Get("/datasources/edit/*", reqSignedIn, hs.Index)
r.Get("/org/users", reqSignedIn, hs.Index)
r.Get("/org/users/new", reqSignedIn, hs.Index)
r.Get("/org/users/invite", reqSignedIn, hs.Index)
r.Get("/org/teams", reqSignedIn, hs.Index)
r.Get("/org/teams/*", reqSignedIn, hs.Index)
r.Get("/org/apikeys/", reqSignedIn, hs.Index)
r.Get("/org/", reqOrgAdmin, hs.Index)
r.Get("/org/new", reqGrafanaAdmin, hs.Index)
r.Get("/datasources/", reqOrgAdmin, hs.Index)
r.Get("/datasources/new", reqOrgAdmin, hs.Index)
r.Get("/datasources/edit/*", reqOrgAdmin, hs.Index)
r.Get("/org/users", reqOrgAdmin, hs.Index)
r.Get("/org/users/new", reqOrgAdmin, hs.Index)
r.Get("/org/users/invite", reqOrgAdmin, hs.Index)
r.Get("/org/teams", reqOrgAdmin, hs.Index)
r.Get("/org/teams/*", reqOrgAdmin, hs.Index)
r.Get("/org/apikeys/", reqOrgAdmin, hs.Index)
r.Get("/dashboard/import/", reqSignedIn, hs.Index)
r.Get("/configuration", reqGrafanaAdmin, hs.Index)
r.Get("/admin", reqGrafanaAdmin, hs.Index)
@@ -73,12 +73,12 @@ func (hs *HTTPServer) registerRoutes() {
r.Get("/dashboards/", reqSignedIn, hs.Index)
r.Get("/dashboards/*", reqSignedIn, hs.Index)
r.Get("/explore", reqEditorRole, hs.Index)
r.Get("/explore", reqSignedIn, middleware.EnsureEditorOrViewerCanEdit, hs.Index)
r.Get("/playlists/", reqSignedIn, hs.Index)
r.Get("/playlists/*", reqSignedIn, hs.Index)
r.Get("/alerting/", reqSignedIn, hs.Index)
r.Get("/alerting/*", reqSignedIn, hs.Index)
r.Get("/alerting/", reqEditorRole, hs.Index)
r.Get("/alerting/*", reqEditorRole, hs.Index)
// sign up
r.Get("/signup", hs.Index)

View File

@@ -145,7 +145,7 @@ func (hs *HTTPServer) setIndexViewData(c *m.ReqContext) (*dtos.IndexViewData, er
Text: "Explore",
Id: "explore",
SubTitle: "Explore your data",
Icon: "fa fa-rocket",
Icon: "gicon gicon-explore",
Url: setting.AppSubUrl + "/explore",
})
}

View File

@@ -52,8 +52,10 @@ func populateDashboardsByTag(orgID int64, signedInUser *m.SignedInUser, dashboar
for _, item := range searchQuery.Result {
result = append(result, dtos.PlaylistDashboard{
Id: item.Id,
Slug: item.Slug,
Title: item.Title,
Uri: item.Uri,
Url: m.GetDashboardUrl(item.Uid, item.Slug),
Order: dashboardTagOrder[tag],
})
}

View File

@@ -121,7 +121,7 @@ func GetUserTeams(c *m.ReqContext) Response {
return getUserTeamList(c.OrgId, c.ParamsInt64(":id"))
}
func getUserTeamList(userID int64, orgID int64) Response {
func getUserTeamList(orgID int64, userID int64) Response {
query := m.GetTeamsByUserQuery{OrgId: orgID, UserId: userID}
if err := bus.Dispatch(&query); err != nil {

View File

@@ -57,6 +57,8 @@ func installCommand(c CommandLine) error {
return InstallPlugin(pluginToInstall, version, c)
}
// InstallPlugin downloads the plugin code as a zip file from the Grafana.com API
// and then extracts the zip into the plugins directory.
func InstallPlugin(pluginName, version string, c CommandLine) error {
pluginFolder := c.PluginDirectory()
downloadURL := c.PluginURL()
@@ -152,6 +154,10 @@ func downloadFile(pluginName, filePath, url string) (err error) {
return err
}
return extractFiles(body, pluginName, filePath)
}
func extractFiles(body []byte, pluginName string, filePath string) error {
r, err := zip.NewReader(bytes.NewReader(body), int64(len(body)))
if err != nil {
return err
@@ -161,12 +167,18 @@ func downloadFile(pluginName, filePath, url string) (err error) {
if zf.FileInfo().IsDir() {
err := os.Mkdir(newFile, 0777)
if PermissionsError(err) {
if permissionsError(err) {
return fmt.Errorf(permissionsDeniedMessage, newFile)
}
} else {
dst, err := os.Create(newFile)
if PermissionsError(err) {
fileMode := zf.Mode()
if strings.HasSuffix(newFile, "_linux_amd64") || strings.HasSuffix(newFile, "_darwin_amd64") {
fileMode = os.FileMode(0755)
}
dst, err := os.OpenFile(newFile, os.O_RDWR|os.O_CREATE|os.O_TRUNC, fileMode)
if permissionsError(err) {
return fmt.Errorf(permissionsDeniedMessage, newFile)
}
@@ -184,6 +196,6 @@ func downloadFile(pluginName, filePath, url string) (err error) {
return nil
}
func PermissionsError(err error) bool {
func permissionsError(err error) bool {
return err != nil && strings.Contains(err.Error(), "permission denied")
}

View File

@@ -1,6 +1,8 @@
package commands
import (
"io/ioutil"
"os"
"testing"
. "github.com/smartystreets/goconvey/convey"
@@ -37,3 +39,42 @@ func TestFoldernameReplacement(t *testing.T) {
})
})
}
func TestExtractFiles(t *testing.T) {
Convey("Should preserve file permissions for plugin backend binaries for linux and darwin", t, func() {
err := os.RemoveAll("testdata/fake-plugins-dir")
So(err, ShouldBeNil)
err = os.MkdirAll("testdata/fake-plugins-dir", 0774)
So(err, ShouldBeNil)
body, err := ioutil.ReadFile("testdata/grafana-simple-json-datasource-ec18fa4da8096a952608a7e4c7782b4260b41bcf.zip")
So(err, ShouldBeNil)
err = extractFiles(body, "grafana-simple-json-datasource", "testdata/fake-plugins-dir")
So(err, ShouldBeNil)
//File in zip has permissions 777
fileInfo, err := os.Stat("testdata/fake-plugins-dir/grafana-simple-json-datasource/simple-plugin_darwin_amd64")
So(err, ShouldBeNil)
So(fileInfo.Mode().String(), ShouldEqual, "-rwxr-xr-x")
//File in zip has permission 664
fileInfo, err = os.Stat("testdata/fake-plugins-dir/grafana-simple-json-datasource/simple-plugin_linux_amd64")
So(err, ShouldBeNil)
So(fileInfo.Mode().String(), ShouldEqual, "-rwxr-xr-x")
//File in zip has permission 644
fileInfo, err = os.Stat("testdata/fake-plugins-dir/grafana-simple-json-datasource/simple-plugin_windows_amd64.exe")
So(err, ShouldBeNil)
So(fileInfo.Mode().String(), ShouldEqual, "-rw-r--r--")
//File in zip has permission 755
fileInfo, err = os.Stat("testdata/fake-plugins-dir/grafana-simple-json-datasource/non-plugin-binary")
So(err, ShouldBeNil)
So(fileInfo.Mode().String(), ShouldEqual, "-rwxr-xr-x")
err = os.RemoveAll("testdata/fake-plugins-dir")
So(err, ShouldBeNil)
})
}

View File

@@ -29,6 +29,8 @@ import (
// self registering services
_ "github.com/grafana/grafana/pkg/extensions"
_ "github.com/grafana/grafana/pkg/infra/serverlock"
_ "github.com/grafana/grafana/pkg/infra/usagestats"
_ "github.com/grafana/grafana/pkg/metrics"
_ "github.com/grafana/grafana/pkg/plugins"
_ "github.com/grafana/grafana/pkg/services/alerting"

View File

@@ -25,6 +25,7 @@ var filters map[string]log15.Lvl
func init() {
loggersToClose = make([]DisposableHandler, 0)
loggersToReload = make([]ReloadableHandler, 0)
filters = map[string]log15.Lvl{}
Root = log15.Root()
Root.SetHandler(log15.DiscardHandler())
}
@@ -197,7 +198,7 @@ func ReadLoggingConfig(modes []string, logsPath string, cfg *ini.File) {
// Log level.
_, level := getLogLevelFromConfig("log."+mode, defaultLevelName, cfg)
filters := getFilters(util.SplitString(sec.Key("filters").String()))
modeFilters := getFilters(util.SplitString(sec.Key("filters").String()))
format := getLogFormat(sec.Key("format").MustString(""))
var handler log15.Handler
@@ -230,12 +231,18 @@ func ReadLoggingConfig(modes []string, logsPath string, cfg *ini.File) {
}
for key, value := range defaultFilters {
if _, exist := modeFilters[key]; !exist {
modeFilters[key] = value
}
}
for key, value := range modeFilters {
if _, exist := filters[key]; !exist {
filters[key] = value
}
}
handler = LogFilterHandler(level, filters, handler)
handler = LogFilterHandler(level, modeFilters, handler)
handlers = append(handlers, handler)
}

View File

@@ -18,6 +18,7 @@ import (
type ILdapConn interface {
Bind(username, password string) error
UnauthenticatedBind(username string) error
Search(*ldap.SearchRequest) (*ldap.SearchResult, error)
StartTLS(*tls.Config) error
Close()
@@ -218,8 +219,18 @@ func (a *ldapAuther) GetGrafanaUserFor(ctx *m.ReqContext, ldapUser *LdapUserInfo
}
func (a *ldapAuther) serverBind() error {
bindFn := func() error {
return a.conn.Bind(a.server.BindDN, a.server.BindPassword)
}
if a.server.BindPassword == "" {
bindFn = func() error {
return a.conn.UnauthenticatedBind(a.server.BindDN)
}
}
// bind_dn and bind_password to bind
if err := a.conn.Bind(a.server.BindDN, a.server.BindPassword); err != nil {
if err := bindFn(); err != nil {
a.log.Info("LDAP initial bind failed, %v", err)
if ldapErr, ok := err.(*ldap.Error); ok {
@@ -259,7 +270,17 @@ func (a *ldapAuther) initialBind(username, userPassword string) error {
bindPath = fmt.Sprintf(a.server.BindDN, username)
}
if err := a.conn.Bind(bindPath, userPassword); err != nil {
bindFn := func() error {
return a.conn.Bind(bindPath, userPassword)
}
if userPassword == "" {
bindFn = func() error {
return a.conn.UnauthenticatedBind(bindPath)
}
}
if err := bindFn(); err != nil {
a.log.Info("Initial bind failed", "error", err)
if ldapErr, ok := err.(*ldap.Error); ok {

View File

@@ -13,6 +13,133 @@ import (
)
func TestLdapAuther(t *testing.T) {
Convey("initialBind", t, func() {
Convey("Given bind dn and password configured", func() {
conn := &mockLdapConn{}
var actualUsername, actualPassword string
conn.bindProvider = func(username, password string) error {
actualUsername = username
actualPassword = password
return nil
}
ldapAuther := &ldapAuther{
conn: conn,
server: &LdapServerConf{
BindDN: "cn=%s,o=users,dc=grafana,dc=org",
BindPassword: "bindpwd",
},
}
err := ldapAuther.initialBind("user", "pwd")
So(err, ShouldBeNil)
So(ldapAuther.requireSecondBind, ShouldBeTrue)
So(actualUsername, ShouldEqual, "cn=user,o=users,dc=grafana,dc=org")
So(actualPassword, ShouldEqual, "bindpwd")
})
Convey("Given bind dn configured", func() {
conn := &mockLdapConn{}
var actualUsername, actualPassword string
conn.bindProvider = func(username, password string) error {
actualUsername = username
actualPassword = password
return nil
}
ldapAuther := &ldapAuther{
conn: conn,
server: &LdapServerConf{
BindDN: "cn=%s,o=users,dc=grafana,dc=org",
},
}
err := ldapAuther.initialBind("user", "pwd")
So(err, ShouldBeNil)
So(ldapAuther.requireSecondBind, ShouldBeFalse)
So(actualUsername, ShouldEqual, "cn=user,o=users,dc=grafana,dc=org")
So(actualPassword, ShouldEqual, "pwd")
})
Convey("Given empty bind dn and password", func() {
conn := &mockLdapConn{}
unauthenticatedBindWasCalled := false
var actualUsername string
conn.unauthenticatedBindProvider = func(username string) error {
unauthenticatedBindWasCalled = true
actualUsername = username
return nil
}
ldapAuther := &ldapAuther{
conn: conn,
server: &LdapServerConf{},
}
err := ldapAuther.initialBind("user", "pwd")
So(err, ShouldBeNil)
So(ldapAuther.requireSecondBind, ShouldBeTrue)
So(unauthenticatedBindWasCalled, ShouldBeTrue)
So(actualUsername, ShouldBeEmpty)
})
})
Convey("serverBind", t, func() {
Convey("Given bind dn and password configured", func() {
conn := &mockLdapConn{}
var actualUsername, actualPassword string
conn.bindProvider = func(username, password string) error {
actualUsername = username
actualPassword = password
return nil
}
ldapAuther := &ldapAuther{
conn: conn,
server: &LdapServerConf{
BindDN: "o=users,dc=grafana,dc=org",
BindPassword: "bindpwd",
},
}
err := ldapAuther.serverBind()
So(err, ShouldBeNil)
So(actualUsername, ShouldEqual, "o=users,dc=grafana,dc=org")
So(actualPassword, ShouldEqual, "bindpwd")
})
Convey("Given bind dn configured", func() {
conn := &mockLdapConn{}
unauthenticatedBindWasCalled := false
var actualUsername string
conn.unauthenticatedBindProvider = func(username string) error {
unauthenticatedBindWasCalled = true
actualUsername = username
return nil
}
ldapAuther := &ldapAuther{
conn: conn,
server: &LdapServerConf{
BindDN: "o=users,dc=grafana,dc=org",
},
}
err := ldapAuther.serverBind()
So(err, ShouldBeNil)
So(unauthenticatedBindWasCalled, ShouldBeTrue)
So(actualUsername, ShouldEqual, "o=users,dc=grafana,dc=org")
})
Convey("Given empty bind dn and password", func() {
conn := &mockLdapConn{}
unauthenticatedBindWasCalled := false
var actualUsername string
conn.unauthenticatedBindProvider = func(username string) error {
unauthenticatedBindWasCalled = true
actualUsername = username
return nil
}
ldapAuther := &ldapAuther{
conn: conn,
server: &LdapServerConf{},
}
err := ldapAuther.serverBind()
So(err, ShouldBeNil)
So(unauthenticatedBindWasCalled, ShouldBeTrue)
So(actualUsername, ShouldBeEmpty)
})
})
Convey("When translating ldap user to grafana user", t, func() {
@@ -368,9 +495,23 @@ type mockLdapConn struct {
result *ldap.SearchResult
searchCalled bool
searchAttributes []string
bindProvider func(username, password string) error
unauthenticatedBindProvider func(username string) error
}
func (c *mockLdapConn) Bind(username, password string) error {
if c.bindProvider != nil {
return c.bindProvider(username, password)
}
return nil
}
func (c *mockLdapConn) UnauthenticatedBind(username string) error {
if c.unauthenticatedBindProvider != nil {
return c.unauthenticatedBindProvider(username)
}
return nil
}

View File

@@ -4,7 +4,7 @@ import (
"net/url"
"strings"
"gopkg.in/macaron.v1"
macaron "gopkg.in/macaron.v1"
m "github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/setting"
@@ -52,6 +52,12 @@ func notAuthorized(c *m.ReqContext) {
c.Redirect(setting.AppSubUrl + "/login")
}
func EnsureEditorOrViewerCanEdit(c *m.ReqContext) {
if !c.SignedInUser.HasRole(m.ROLE_EDITOR) && !setting.ViewersCanEdit {
accessForbidden(c)
}
}
func RoleAuth(roles ...m.RoleType) macaron.Handler {
return func(c *m.ReqContext) {
ok := false

View File

@@ -95,52 +95,9 @@ func (s *SimpleReducer) Reduce(series *tsdb.TimeSeries) null.Float {
}
}
case "diff":
var (
points = series.Points
first float64
i int
)
// get the newest point
for i = len(points) - 1; i >= 0; i-- {
if points[i][0].Valid {
allNull = false
first = points[i][0].Float64
break
}
}
// get the oldest point
points = points[0:i]
for i := 0; i < len(points); i++ {
if points[i][0].Valid {
allNull = false
value = first - points[i][0].Float64
break
}
}
allNull, value = calculateDiff(series, allNull, value, diff)
case "percent_diff":
var (
points = series.Points
first float64
i int
)
// get the newest point
for i = len(points) - 1; i >= 0; i-- {
if points[i][0].Valid {
allNull = false
first = points[i][0].Float64
break
}
}
// get the oldest point
points = points[0:i]
for i := 0; i < len(points); i++ {
if points[i][0].Valid {
allNull = false
val := (first - points[i][0].Float64) / points[i][0].Float64 * 100
value = math.Abs(val)
break
}
}
allNull, value = calculateDiff(series, allNull, value, percentDiff)
case "count_non_null":
for _, v := range series.Points {
if v[0].Valid {
@@ -163,3 +120,40 @@ func (s *SimpleReducer) Reduce(series *tsdb.TimeSeries) null.Float {
func NewSimpleReducer(typ string) *SimpleReducer {
return &SimpleReducer{Type: typ}
}
func calculateDiff(series *tsdb.TimeSeries, allNull bool, value float64, fn func(float64, float64) float64) (bool, float64) {
var (
points = series.Points
first float64
i int
)
// get the newest point
for i = len(points) - 1; i >= 0; i-- {
if points[i][0].Valid {
allNull = false
first = points[i][0].Float64
break
}
}
if i >= 1 {
// get the oldest point
points = points[0:i]
for i := 0; i < len(points); i++ {
if points[i][0].Valid {
allNull = false
val := fn(first, points[i][0].Float64)
value = math.Abs(val)
break
}
}
}
return allNull, value
}
var diff = func(newest, oldest float64) float64 {
return newest - oldest
}
var percentDiff = func(newest, oldest float64) float64 {
return (newest - oldest) / oldest * 100
}

View File

@@ -143,6 +143,18 @@ func TestSimpleReducer(t *testing.T) {
So(result, ShouldEqual, float64(10))
})
Convey("diff with only nulls", func() {
reducer := NewSimpleReducer("diff")
series := &tsdb.TimeSeries{
Name: "test time serie",
}
series.Points = append(series.Points, tsdb.NewTimePoint(null.FloatFromPtr(nil), 1))
series.Points = append(series.Points, tsdb.NewTimePoint(null.FloatFromPtr(nil), 2))
So(reducer.Reduce(series).Valid, ShouldEqual, false)
})
Convey("percent_diff one point", func() {
result := testReducer("percent_diff", 40)
So(result, ShouldEqual, float64(0))
@@ -157,6 +169,18 @@ func TestSimpleReducer(t *testing.T) {
result := testReducer("percent_diff", 30, 40, 40)
So(result, ShouldEqual, float64(33.33333333333333))
})
Convey("percent_diff with only nulls", func() {
reducer := NewSimpleReducer("percent_diff")
series := &tsdb.TimeSeries{
Name: "test time serie",
}
series.Points = append(series.Points, tsdb.NewTimePoint(null.FloatFromPtr(nil), 1))
series.Points = append(series.Points, tsdb.NewTimePoint(null.FloatFromPtr(nil), 2))
So(reducer.Reduce(series).Valid, ShouldEqual, false)
})
})
}

View File

@@ -104,7 +104,7 @@ func (c *EvalContext) GetDashboardUID() (*m.DashboardRef, error) {
return c.dashboardRef, nil
}
const urlFormat = "%s?fullscreen=true&edit=true&tab=alert&panelId=%d&orgId=%d"
const urlFormat = "%s?fullscreen&edit&tab=alert&panelId=%d&orgId=%d"
func (c *EvalContext) GetRuleUrl() (string, error) {
if c.IsTestRun {

View File

@@ -3,6 +3,7 @@ package alerting
import (
"errors"
"fmt"
"time"
"github.com/grafana/grafana/pkg/bus"
"github.com/grafana/grafana/pkg/components/imguploader"
@@ -126,7 +127,7 @@ func (n *notificationService) uploadImage(context *EvalContext) (err error) {
renderOpts := rendering.Opts{
Width: 1000,
Height: 500,
Timeout: alertTimeout / 2,
Timeout: time.Duration(float64(alertTimeout) * 0.9),
OrgId: context.Rule.OrgId,
OrgRole: m.ROLE_ADMIN,
ConcurrentLimit: setting.AlertingRenderLimit,
@@ -137,7 +138,7 @@ func (n *notificationService) uploadImage(context *EvalContext) (err error) {
return err
}
renderOpts.Path = fmt.Sprintf("d-solo/%s/%s?panelId=%d", ref.Uid, ref.Slug, context.Rule.PanelId)
renderOpts.Path = fmt.Sprintf("d-solo/%s/%s?orgId=%d&panelId=%d", ref.Uid, ref.Slug, context.Rule.OrgId, context.Rule.PanelId)
result, err := n.renderService.Render(context.Ctx, renderOpts)
if err != nil {

View File

@@ -111,31 +111,52 @@ func (this *DiscordNotifier) Notify(evalContext *alerting.EvalContext) error {
json, _ := bodyJSON.MarshalJSON()
content_type := "application/json"
var body []byte
if embeddedImage {
var b bytes.Buffer
w := multipart.NewWriter(&b)
f, err := os.Open(evalContext.ImageOnDiskPath)
cmd := &m.SendWebhookSync{
Url: this.WebhookURL,
HttpMethod: "POST",
ContentType: "application/json",
}
if !embeddedImage {
cmd.Body = string(json)
} else {
err := this.embedImage(cmd, evalContext.ImageOnDiskPath, json)
if err != nil {
this.log.Error("Can't open graph file", err)
this.log.Error("failed to embed image", "error", err)
return err
}
}
if err := bus.DispatchCtx(evalContext.Ctx, cmd); err != nil {
this.log.Error("Failed to send notification to Discord", "error", err)
return err
}
return nil
}
func (this *DiscordNotifier) embedImage(cmd *m.SendWebhookSync, imagePath string, existingJSONBody []byte) error {
f, err := os.Open(imagePath)
defer f.Close()
if err != nil {
if os.IsNotExist(err) {
cmd.Body = string(existingJSONBody)
return nil
}
if !os.IsNotExist(err) {
return err
}
}
var b bytes.Buffer
w := multipart.NewWriter(&b)
fw, err := w.CreateFormField("payload_json")
if err != nil {
return err
}
if _, err = fw.Write([]byte(string(json))); err != nil {
if _, err = fw.Write([]byte(string(existingJSONBody))); err != nil {
return err
}
@@ -150,24 +171,8 @@ func (this *DiscordNotifier) Notify(evalContext *alerting.EvalContext) error {
w.Close()
body = b.Bytes()
content_type = w.FormDataContentType()
} else {
body = json
}
cmd := &m.SendWebhookSync{
Url: this.WebhookURL,
Body: string(body),
HttpMethod: "POST",
ContentType: content_type,
}
if err := bus.DispatchCtx(evalContext.Ctx, cmd); err != nil {
this.log.Error("Failed to send notification to Discord", "error", err)
return err
}
cmd.Body = string(b.Bytes())
cmd.ContentType = w.FormDataContentType()
return nil
}

View File

@@ -36,7 +36,7 @@ func (rs *RenderingService) renderViaPhantomJS(ctx context.Context, opts Opts) (
defer middleware.RemoveRenderAuthKey(renderKey)
phantomDebugArg := "--debug=false"
if log.GetLogLevelFor("renderer") >= log.LvlDebug {
if log.GetLogLevelFor("rendering") >= log.LvlDebug {
phantomDebugArg = "--debug=true"
}
@@ -64,13 +64,26 @@ func (rs *RenderingService) renderViaPhantomJS(ctx context.Context, opts Opts) (
cmd := exec.CommandContext(commandCtx, binPath, cmdArgs...)
cmd.Stderr = cmd.Stdout
timezone := ""
if opts.Timezone != "" {
timezone = isoTimeOffsetToPosixTz(opts.Timezone)
baseEnviron := os.Environ()
cmd.Env = appendEnviron(baseEnviron, "TZ", isoTimeOffsetToPosixTz(opts.Timezone))
cmd.Env = appendEnviron(baseEnviron, "TZ", timezone)
}
rs.log.Debug("executing Phantomjs", "binPath", binPath, "cmdArgs", cmdArgs, "timezone", timezone)
out, err := cmd.Output()
if out != nil {
rs.log.Debug("Phantomjs output", "out", string(out))
}
if err != nil {
rs.log.Debug("Phantomjs error", "error", err)
}
// check for timeout first
if commandCtx.Err() == context.DeadlineExceeded {
rs.log.Info("Rendering timed out")
@@ -82,8 +95,6 @@ func (rs *RenderingService) renderViaPhantomJS(ctx context.Context, opts Opts) (
return nil, err
}
rs.log.Debug("Phantomjs output", "out", string(out))
rs.log.Debug("Image rendered", "path", pngPath)
return &RenderResult{FilePath: pngPath}, nil
}

View File

@@ -17,6 +17,7 @@ type Hit struct {
Title string `json:"title"`
Uri string `json:"uri"`
Url string `json:"url"`
Slug string `json:"slug"`
Type HitType `json:"type"`
Tags []string `json:"tags"`
IsStarred bool `json:"isStarred"`

View File

@@ -44,6 +44,10 @@ func DeleteOldLoginAttempts(cmd *m.DeleteOldLoginAttemptsCommand) error {
return err
}
if result == nil || len(result) == 0 || result[0] == nil {
return nil
}
maxId = toInt64(result[0]["id"])
if maxId == 0 {

View File

@@ -33,6 +33,7 @@ func init() {
renders["sum"] = QueryDefinition{Renderer: functionRenderer}
renders["mode"] = QueryDefinition{Renderer: functionRenderer}
renders["cumulative_sum"] = QueryDefinition{Renderer: functionRenderer}
renders["non_negative_difference"] = QueryDefinition{Renderer: functionRenderer}
renders["holt_winters"] = QueryDefinition{
Renderer: functionRenderer,

View File

@@ -24,6 +24,7 @@ func TestInfluxdbQueryPart(t *testing.T) {
{mode: "count", params: []string{}, input: "distinct(value)", expected: `count(distinct(value))`},
{mode: "mode", params: []string{}, input: "value", expected: `mode(value)`},
{mode: "cumulative_sum", params: []string{}, input: "mean(value)", expected: `cumulative_sum(mean(value))`},
{mode: "non_negative_difference", params: []string{}, input: "max(value)", expected: `non_negative_difference(max(value))`},
}
queryContext := &tsdb.TsdbQuery{TimeRange: tsdb.NewTimeRange("5m", "now")}

View File

@@ -336,6 +336,8 @@ func (e *StackdriverExecutor) unmarshalResponse(res *http.Response) (Stackdriver
return StackdriverResponse{}, err
}
// slog.Info("stackdriver", "response", string(body))
if res.StatusCode/100 != 2 {
slog.Error("Request failed", "status", res.Status, "body", string(body))
return StackdriverResponse{}, fmt.Errorf(string(body))
@@ -559,7 +561,7 @@ func calcBucketBound(bucketOptions StackdriverBucketOptions, n int) string {
} else if bucketOptions.ExponentialBuckets != nil {
bucketBound = strconv.FormatInt(int64(bucketOptions.ExponentialBuckets.Scale*math.Pow(bucketOptions.ExponentialBuckets.GrowthFactor, float64(n-1))), 10)
} else if bucketOptions.ExplicitBuckets != nil {
bucketBound = strconv.FormatInt(bucketOptions.ExplicitBuckets.Bounds[(n-1)], 10)
bucketBound = fmt.Sprintf("%g", bucketOptions.ExplicitBuckets.Bounds[n])
}
return bucketBound
}

View File

@@ -344,8 +344,8 @@ func TestStackdriver(t *testing.T) {
})
})
Convey("when data from query is distribution", func() {
data, err := loadTestFile("./test-data/3-series-response-distribution.json")
Convey("when data from query is distribution with exponential bounds", func() {
data, err := loadTestFile("./test-data/3-series-response-distribution-exponential.json")
So(err, ShouldBeNil)
So(len(data.TimeSeries), ShouldEqual, 1)
@@ -370,6 +370,14 @@ func TestStackdriver(t *testing.T) {
So(res.Series[0].Points[2][1].Float64, ShouldEqual, 1536669060000)
})
Convey("bucket bounds should be correct", func() {
So(res.Series[0].Name, ShouldEqual, "0")
So(res.Series[1].Name, ShouldEqual, "1")
So(res.Series[2].Name, ShouldEqual, "2")
So(res.Series[3].Name, ShouldEqual, "4")
So(res.Series[4].Name, ShouldEqual, "8")
})
Convey("value should be correct", func() {
So(res.Series[8].Points[0][0].Float64, ShouldEqual, 1)
So(res.Series[9].Points[0][0].Float64, ShouldEqual, 1)
@@ -383,6 +391,45 @@ func TestStackdriver(t *testing.T) {
})
})
Convey("when data from query is distribution with explicit bounds", func() {
data, err := loadTestFile("./test-data/4-series-response-distribution-explicit.json")
So(err, ShouldBeNil)
So(len(data.TimeSeries), ShouldEqual, 1)
res := &tsdb.QueryResult{Meta: simplejson.New(), RefId: "A"}
query := &StackdriverQuery{AliasBy: "{{bucket}}"}
err = executor.parseResponse(res, data, query)
So(err, ShouldBeNil)
So(len(res.Series), ShouldEqual, 33)
for i := 0; i < 33; i++ {
if i == 0 {
So(res.Series[i].Name, ShouldEqual, "0")
}
So(len(res.Series[i].Points), ShouldEqual, 2)
}
Convey("timestamps should be in ascending order", func() {
So(res.Series[0].Points[0][1].Float64, ShouldEqual, 1550859086000)
So(res.Series[0].Points[1][1].Float64, ShouldEqual, 1550859146000)
})
Convey("bucket bounds should be correct", func() {
So(res.Series[0].Name, ShouldEqual, "0")
So(res.Series[1].Name, ShouldEqual, "0.01")
So(res.Series[2].Name, ShouldEqual, "0.05")
So(res.Series[3].Name, ShouldEqual, "0.1")
})
Convey("value should be correct", func() {
So(res.Series[8].Points[0][0].Float64, ShouldEqual, 381)
So(res.Series[9].Points[0][0].Float64, ShouldEqual, 212)
So(res.Series[10].Points[0][0].Float64, ShouldEqual, 56)
So(res.Series[8].Points[1][0].Float64, ShouldEqual, 375)
So(res.Series[9].Points[1][0].Float64, ShouldEqual, 213)
So(res.Series[10].Points[1][0].Float64, ShouldEqual, 56)
})
})
})
Convey("when interpolating filter wildcards", func() {

View File

@@ -0,0 +1,209 @@
{
"timeSeries": [
{
"metric": {
"type": "custom.googleapis.com\/opencensus\/grpc.io\/client\/roundtrip_latency"
},
"resource": {
"type": "global",
"labels": {
"project_id": "grafana-demo"
}
},
"metricKind": "DELTA",
"valueType": "DISTRIBUTION",
"points": [
{
"interval": {
"startTime": "2019-02-22T18:11:26Z",
"endTime": "2019-02-22T18:12:26Z"
},
"value": {
"distributionValue": {
"count": "1878",
"mean": 17.813718392255,
"sumOfSquaredDeviation": 7141630.651914,
"bucketOptions": {
"explicitBuckets": {
"bounds": [
0,
0.01,
0.05,
0.1,
0.3,
0.6,
0.8,
1,
2,
3,
4,
5,
6,
8,
10,
13,
16,
20,
25,
30,
40,
50,
65,
80,
100,
130,
160,
200,
250,
300,
400,
500,
650,
800,
1000,
2000,
5000,
10000,
20000,
50000,
100000
]
}
},
"bucketCounts": [
"0",
"0",
"0",
"0",
"8",
"403",
"297",
"184",
"375",
"213",
"56",
"31",
"15",
"13",
"4",
"1",
"5",
"2",
"8",
"13",
"26",
"13",
"45",
"48",
"61",
"10",
"3",
"6",
"7",
"4",
"7",
"12",
"8"
]
}
}
},
{
"interval": {
"startTime": "2019-02-22T18:10:26Z",
"endTime": "2019-02-22T18:11:26Z"
},
"value": {
"distributionValue": {
"count": "1887",
"mean": 17.654277577766,
"sumOfSquaredDeviation": 7082587.2133073,
"bucketOptions": {
"explicitBuckets": {
"bounds": [
0,
0.01,
0.05,
0.1,
0.3,
0.6,
0.8,
1,
2,
3,
4,
5,
6,
8,
10,
13,
16,
20,
25,
30,
40,
50,
65,
80,
100,
130,
160,
200,
250,
300,
400,
500,
650,
800,
1000,
2000,
5000,
10000,
20000,
50000,
100000
]
}
},
"bucketCounts": [
"0",
"0",
"0",
"0",
"8",
"404",
"298",
"187",
"381",
"212",
"56",
"31",
"15",
"14",
"4",
"1",
"4",
"2",
"9",
"13",
"24",
"13",
"46",
"46",
"61",
"11",
"3",
"6",
"7",
"5",
"7",
"11",
"8"
]
}
}
}
]
}
]
}

View File

@@ -26,7 +26,7 @@ type StackdriverBucketOptions struct {
Scale float64 `json:"scale"`
} `json:"exponentialBuckets"`
ExplicitBuckets *struct {
Bounds []int64 `json:"bounds"`
Bounds []float64 `json:"bounds"`
} `json:"explicitBuckets"`
}

View File

@@ -10,10 +10,12 @@ import { SideMenu } from './components/sidemenu/SideMenu';
import { MetricSelect } from './components/Select/MetricSelect';
import AppNotificationList from './components/AppNotifications/AppNotificationList';
import { ColorPicker, SeriesColorPickerPopoverWithTheme } from '@grafana/ui';
import { FunctionEditor } from 'app/plugins/datasource/graphite/FunctionEditor';
export function registerAngularDirectives() {
react2AngularDirective('passwordStrength', PasswordStrength, ['password']);
react2AngularDirective('sidemenu', SideMenu, []);
react2AngularDirective('functionEditor', FunctionEditor, ['func', 'onRemove', 'onMoveLeft', 'onMoveRight']);
react2AngularDirective('appNotificationsList', AppNotificationList, []);
react2AngularDirective('pageHeader', PageHeader, ['model', 'noTabs']);
react2AngularDirective('emptyListCta', EmptyListCTA, ['model']);

View File

@@ -61,8 +61,7 @@ export default class PermissionsListItem extends PureComponent<Props> {
{item.name} <ItemDescription item={item} />
</td>
<td>
{item.inherited &&
folderInfo && (
{item.inherited && folderInfo && (
<em className="muted no-wrap">
Inherited from folder{' '}
<a className="text-link" href={`${folderInfo.url}/permissions`}>

View File

@@ -47,7 +47,8 @@ class BottomNavLinks extends PureComponent<Props> {
<div className="sidemenu-org-switcher__org-current">Current Org:</div>
</div>
<div className="sidemenu-org-switcher__switch">
<i className="fa fa-fw fa-random" />Switch
<i className="fa fa-fw fa-random" />
Switch
</div>
</a>
</li>

View File

@@ -29,7 +29,8 @@ export class SideMenu extends PureComponent {
<div className="sidemenu__logo_small_breakpoint" onClick={this.toggleSideMenuSmallBreakpoint} key="hamburger">
<i className="fa fa-bars" />
<span className="sidemenu__close">
<i className="fa fa-times" />&nbsp;Close
<i className="fa fa-times" />
&nbsp;Close
</span>
</div>,
<TopSection key="topsection" />,

View File

@@ -14,4 +14,3 @@ export const DASHBOARD_TOP_PADDING = 20;
export const PANEL_HEADER_HEIGHT = 27;
export const PANEL_BORDER = 2;
export const PANEL_OPTIONS_KEY_PREFIX = 'options-';

View File

@@ -5,7 +5,7 @@ export class JsonEditorCtrl {
/** @ngInject */
constructor($scope) {
$scope.json = angular.toJson($scope.model.object, true);
$scope.canUpdate = $scope.model.updateHandler !== void 0 && $scope.contextSrv.isEditor;
$scope.canUpdate = $scope.model.updateHandler !== void 0 && $scope.model.canUpdate;
$scope.canCopy = $scope.model.enableCopy;
$scope.update = () => {

View File

@@ -128,7 +128,7 @@ export function dropdownTypeahead2($compile) {
'<input type="text"' + ' class="gf-form-input"' + ' spellcheck="false" style="display:none"></input>';
const buttonTemplate =
'<a class="gf-form-input dropdown-toggle"' +
'<a class="{{buttonTemplateClass}} dropdown-toggle"' +
' tabindex="1" gf-dropdown="menuItems" data-toggle="dropdown"' +
' ><i class="fa fa-plus"></i></a>';
@@ -137,9 +137,15 @@ export function dropdownTypeahead2($compile) {
menuItems: '=dropdownTypeahead2',
dropdownTypeaheadOnSelect: '&dropdownTypeaheadOnSelect',
model: '=ngModel',
buttonTemplateClass: '@',
},
link: ($scope, elem, attrs) => {
const $input = $(inputTemplate);
if (!$scope.buttonTemplateClass) {
$scope.buttonTemplateClass = 'gf-form-input';
}
const $button = $(buttonTemplate);
const timeoutId = {
blur: null,

View File

@@ -240,7 +240,7 @@ export class ValueSelectDropdownCtrl {
/** @ngInject */
export function valueSelectDropdown($compile, $window, $timeout, $rootScope) {
return {
scope: { variable: '=', onUpdated: '&' },
scope: { dashboard: '=', variable: '=', onUpdated: '&' },
templateUrl: 'public/app/partials/valueSelectDropdown.html',
controller: 'ValueSelectDropdownCtrl',
controllerAs: 'vm',
@@ -288,13 +288,13 @@ export function valueSelectDropdown($compile, $window, $timeout, $rootScope) {
}
});
const cleanUp = $rootScope.$on('template-variable-value-updated', () => {
scope.vm.dashboard.on(
'template-variable-value-updated',
() => {
scope.vm.updateLinkText();
});
scope.$on('$destroy', () => {
cleanUp();
});
},
scope
);
scope.vm.init();
},

View File

@@ -27,7 +27,9 @@ export class AngularLoader {
compiledElem.remove();
},
digest: () => {
if (!scope.$$phase) {
scope.$digest();
}
},
getScope: () => {
return scope;

View File

@@ -0,0 +1,55 @@
import { getMessageFromError } from 'app/core/utils/errors';
describe('errors functions', () => {
let message;
describe('when getMessageFromError gets an error string', () => {
beforeEach(() => {
message = getMessageFromError('error string');
});
it('should return the string', () => {
expect(message).toBe('error string');
});
});
describe('when getMessageFromError gets an error object with message field', () => {
beforeEach(() => {
message = getMessageFromError({ message: 'error string' });
});
it('should return the message text', () => {
expect(message).toBe('error string');
});
});
describe('when getMessageFromError gets an error object with data.message field', () => {
beforeEach(() => {
message = getMessageFromError({ data: { message: 'error string' } });
});
it('should return the message text', () => {
expect(message).toBe('error string');
});
});
describe('when getMessageFromError gets an error object with statusText field', () => {
beforeEach(() => {
message = getMessageFromError({ statusText: 'error string' });
});
it('should return the statusText text', () => {
expect(message).toBe('error string');
});
});
describe('when getMessageFromError gets an error object', () => {
beforeEach(() => {
message = getMessageFromError({ customError: 'error string' });
});
it('should return the stringified error', () => {
expect(message).toBe('{"customError":"error string"}');
});
});
});

View File

@@ -13,5 +13,5 @@ export function getMessageFromError(err: any): string | null {
}
}
return null;
return err;
}

View File

@@ -153,7 +153,7 @@ export function buildQueryTransaction(
};
}
export const clearQueryKeys: ((query: DataQuery) => object) = ({ key, refId, ...rest }) => rest;
export const clearQueryKeys: (query: DataQuery) => object = ({ key, refId, ...rest }) => rest;
const isMetricSegment = (segment: { [key: string]: string }) => segment.hasOwnProperty('expr');
const isUISegment = (segment: { [key: string]: string }) => segment.hasOwnProperty('ui');

View File

@@ -143,7 +143,7 @@ kbn.secondsToHhmmss = seconds => {
};
kbn.to_percent = (nr, outof) => {
return Math.floor(nr / outof * 10000) / 100 + '%';
return Math.floor((nr / outof) * 10000) / 100 + '%';
};
kbn.addslashes = str => {

View File

@@ -29,7 +29,7 @@ class AlertRuleItem extends PureComponent<Props> {
'fa-pause': rule.state !== 'paused',
});
const ruleUrl = `${rule.url}?panelId=${rule.panelId}&fullscreen=true&edit=true&tab=alert`;
const ruleUrl = `${rule.url}?panelId=${rule.panelId}&fullscreen&edit&tab=alert`;
return (
<li className="alert-rule-item">

View File

@@ -149,4 +149,9 @@ const mapDispatchToProps = {
togglePauseAlertRule,
};
export default hot(module)(connect(mapStateToProps, mapDispatchToProps)(AlertRuleList));
export default hot(module)(
connect(
mapStateToProps,
mapDispatchToProps
)(AlertRuleList)
);

View File

@@ -21,7 +21,7 @@ exports[`Render should render component 1`] = `
className="alert-rule-item__name"
>
<a
href="https://something.something.darkside?panelId=1&fullscreen=true&edit=true&tab=alert"
href="https://something.something.darkside?panelId=1&fullscreen&edit&tab=alert"
>
<Highlighter
highlightClassName="highlight-search-match"
@@ -73,7 +73,7 @@ exports[`Render should render component 1`] = `
</button>
<a
className="btn btn-small btn-inverse alert-list__btn width-2"
href="https://something.something.darkside?panelId=1&fullscreen=true&edit=true&tab=alert"
href="https://something.something.darkside?panelId=1&fullscreen&edit&tab=alert"
title="Edit alert rule"
>
<i

View File

@@ -263,4 +263,9 @@ const mapDispatchToProps = {
addApiKey,
};
export default hot(module)(connect(mapStateToProps, mapDispatchToProps)(ApiKeysPage));
export default hot(module)(
connect(
mapStateToProps,
mapDispatchToProps
)(ApiKeysPage)
);

View File

@@ -248,7 +248,7 @@ export class DashNav extends PureComponent<Props> {
<div className="navbar-buttons navbar-buttons--tv">
<DashNavButton
tooltip="Cycke view mode"
tooltip="Cycle view mode"
classSuffix="tv"
icon="fa fa-desktop"
onClick={this.onToggleTVMode}
@@ -267,4 +267,7 @@ const mapDispatchToProps = {
updateLocation,
};
export default connect(mapStateToProps, mapDispatchToProps)(DashNav);
export default connect(
mapStateToProps,
mapDispatchToProps
)(DashNav);

View File

@@ -4,7 +4,7 @@
<label class="gf-form-label template-variable" ng-hide="variable.hide === 1">
{{variable.label || variable.name}}
</label>
<value-select-dropdown ng-if="variable.type !== 'adhoc' && variable.type !== 'textbox'" variable="variable" on-updated="ctrl.variableUpdated(variable)"></value-select-dropdown>
<value-select-dropdown ng-if="variable.type !== 'adhoc' && variable.type !== 'textbox'" dashboard="ctrl.dashboard" variable="variable" on-updated="ctrl.variableUpdated(variable)"></value-select-dropdown>
<input type="text" ng-if="variable.type === 'textbox'" ng-model="variable.query" class="gf-form-input width-12" ng-blur="variable.current.value != variable.query && variable.updateOptions() && ctrl.variableUpdated(variable);" ng-keydown="$event.keyCode === 13 && variable.current.value != variable.query && variable.updateOptions() && ctrl.variableUpdated(variable);" ></input>
</div>
<ad-hoc-filters ng-if="variable.type === 'adhoc'" variable="variable" dashboard="ctrl.dashboard"></ad-hoc-filters>

View File

@@ -1,6 +1,6 @@
import React from 'react';
import { shallow, ShallowWrapper } from 'enzyme';
import { DashboardPage, Props, State } from './DashboardPage';
import { DashboardPage, Props, State, mapStateToProps } from './DashboardPage';
import { DashboardModel } from '../state';
import { cleanUpDashboard } from '../state/actions';
import { getNoPayloadActionCreatorMock, NoPayloadActionCreatorMock } from 'app/core/redux';
@@ -250,4 +250,36 @@ describe('DashboardPage', () => {
expect(ctx.cleanUpDashboardMock.calls).toBe(1);
});
});
describe('mapStateToProps with bool fullscreen', () => {
const props = mapStateToProps({
location: {
routeParams: {},
query: {
fullscreen: true,
edit: false,
},
},
dashboard: {},
} as any);
expect(props.urlFullscreen).toBe(true);
expect(props.urlEdit).toBe(false);
});
describe('mapStateToProps with string edit true', () => {
const props = mapStateToProps({
location: {
routeParams: {},
query: {
fullscreen: false,
edit: 'true',
},
},
dashboard: {},
} as any);
expect(props.urlFullscreen).toBe(false);
expect(props.urlEdit).toBe(true);
});
});

View File

@@ -92,11 +92,12 @@ export class DashboardPage extends PureComponent<Props, State> {
componentWillUnmount() {
if (this.props.dashboard) {
this.props.cleanUpDashboard();
this.setPanelFullscreenClass(false);
}
}
componentDidUpdate(prevProps: Props) {
const { dashboard, editview, urlEdit, urlFullscreen, urlPanelId } = this.props;
const { dashboard, editview, urlEdit, urlFullscreen, urlPanelId, urlUid } = this.props;
if (!dashboard) {
return;
@@ -107,6 +108,12 @@ export class DashboardPage extends PureComponent<Props, State> {
document.title = dashboard.title + ' - Grafana';
}
// Due to the angular -> react url bridge we can ge an update here with new uid before the container unmounts
// Can remove this condition after we switch to react router
if (prevProps.urlUid !== urlUid) {
return;
}
// handle animation states when opening dashboard settings
if (!prevProps.editview && editview) {
this.setState({ isSettingsOpening: true });
@@ -163,14 +170,20 @@ export class DashboardPage extends PureComponent<Props, State> {
fullscreenPanel: null,
scrollTop: this.state.rememberScrollTop,
},
() => {
dashboard.render();
}
this.triggerPanelsRendering.bind(this)
);
this.setPanelFullscreenClass(false);
}
triggerPanelsRendering() {
try {
this.props.dashboard.render();
} catch (err) {
this.props.notifyApp(createErrorNotification(`Panel rendering error`, err));
}
}
handleFullscreenPanelNotFound(urlPanelId: string) {
// Panel not found
this.props.notifyApp(createErrorNotification(`Panel with id ${urlPanelId} not found`));
@@ -268,7 +281,12 @@ export class DashboardPage extends PureComponent<Props, State> {
onAddPanel={this.onAddPanel}
/>
<div className="scroll-canvas scroll-canvas--dashboard">
<CustomScrollbar autoHeightMin={'100%'} setScrollTop={this.setScrollTop} scrollTop={scrollTop}>
<CustomScrollbar
autoHeightMin={'100%'}
setScrollTop={this.setScrollTop}
scrollTop={scrollTop}
updateAfterMountMs={500}
>
{editview && <DashboardSettings dashboard={dashboard} />}
{initError && this.renderInitFailedState()}
@@ -284,15 +302,15 @@ export class DashboardPage extends PureComponent<Props, State> {
}
}
const mapStateToProps = (state: StoreState) => ({
export const mapStateToProps = (state: StoreState) => ({
urlUid: state.location.routeParams.uid,
urlSlug: state.location.routeParams.slug,
urlType: state.location.routeParams.type,
editview: state.location.query.editview,
urlPanelId: state.location.query.panelId,
urlFolderId: state.location.query.folderId,
urlFullscreen: state.location.query.fullscreen === true,
urlEdit: state.location.query.edit === true,
urlFullscreen: !!state.location.query.fullscreen,
urlEdit: !!state.location.query.edit,
initPhase: state.dashboard.initPhase,
isInitSlow: state.dashboard.isInitSlow,
initError: state.dashboard.initError,
@@ -306,4 +324,9 @@ const mapDispatchToProps = {
updateLocation,
};
export default hot(module)(connect(mapStateToProps, mapDispatchToProps)(DashboardPage));
export default hot(module)(
connect(
mapStateToProps,
mapDispatchToProps
)(DashboardPage)
);

View File

@@ -107,4 +107,9 @@ const mapDispatchToProps = {
initDashboard,
};
export default hot(module)(connect(mapStateToProps, mapDispatchToProps)(SoloPanelPage));
export default hot(module)(
connect(
mapStateToProps,
mapDispatchToProps
)(SoloPanelPage)
);

View File

@@ -78,7 +78,7 @@ exports[`DashboardPage Dashboard init completed Should render dashboard grid 1`
],
"refresh": undefined,
"revision": undefined,
"schemaVersion": 17,
"schemaVersion": 18,
"snapshot": undefined,
"style": "dark",
"tags": Array [],
@@ -113,6 +113,7 @@ exports[`DashboardPage Dashboard init completed Should render dashboard grid 1`
hideTracksWhenNotNeeded={false}
scrollTop={0}
setScrollTop={[Function]}
updateAfterMountMs={500}
>
<div
className="dashboard-container"
@@ -190,7 +191,7 @@ exports[`DashboardPage Dashboard init completed Should render dashboard grid 1`
],
"refresh": undefined,
"revision": undefined,
"schemaVersion": 17,
"schemaVersion": 18,
"snapshot": undefined,
"style": "dark",
"tags": Array [],
@@ -313,7 +314,7 @@ exports[`DashboardPage When dashboard has editview url state should render setti
],
"refresh": undefined,
"revision": undefined,
"schemaVersion": 17,
"schemaVersion": 18,
"snapshot": undefined,
"style": "dark",
"tags": Array [],
@@ -349,6 +350,7 @@ exports[`DashboardPage When dashboard has editview url state should render setti
hideTracksWhenNotNeeded={false}
scrollTop={0}
setScrollTop={[Function]}
updateAfterMountMs={500}
>
<DashboardSettings
dashboard={
@@ -423,7 +425,7 @@ exports[`DashboardPage When dashboard has editview url state should render setti
],
"refresh": undefined,
"revision": undefined,
"schemaVersion": 17,
"schemaVersion": 18,
"snapshot": undefined,
"style": "dark",
"tags": Array [],
@@ -518,7 +520,7 @@ exports[`DashboardPage When dashboard has editview url state should render setti
],
"refresh": undefined,
"revision": undefined,
"schemaVersion": 17,
"schemaVersion": 18,
"snapshot": undefined,
"style": "dark",
"tags": Array [],

View File

@@ -131,10 +131,10 @@ export class DashboardPanel extends PureComponent<Props, State> {
};
renderReactPanel() {
const { dashboard, panel } = this.props;
const { dashboard, panel, isFullscreen } = this.props;
const { plugin } = this.state;
return <PanelChrome plugin={plugin} panel={panel} dashboard={dashboard} />;
return <PanelChrome plugin={plugin} panel={panel} dashboard={dashboard} isFullscreen={isFullscreen} />;
}
renderAngularPanel() {
@@ -173,7 +173,7 @@ export class DashboardPanel extends PureComponent<Props, State> {
onMouseLeave={this.onMouseLeave}
style={styles}
>
{plugin.exports.Panel && this.renderReactPanel()}
{plugin.exports.reactPanel && this.renderReactPanel()}
{plugin.exports.PanelCtrl && this.renderAngularPanel()}
</div>
)}

View File

@@ -1,8 +1,6 @@
// Library
import React, { Component } from 'react';
import { Tooltip } from '@grafana/ui';
import ErrorBoundary from 'app/core/components/ErrorBoundary/ErrorBoundary';
// Services
import { DatasourceSrv, getDatasourceSrv } from 'app/features/plugins/datasource_srv';
// Utils
@@ -11,6 +9,7 @@ import kbn from 'app/core/utils/kbn';
import {
DataQueryOptions,
DataQueryResponse,
DataQueryError,
LoadingState,
PanelData,
TableData,
@@ -18,8 +17,6 @@ import {
TimeSeries,
} from '@grafana/ui';
const DEFAULT_PLUGIN_ERROR = 'Error in plugin';
interface RenderProps {
loading: LoadingState;
panelData: PanelData;
@@ -38,12 +35,12 @@ export interface Props {
maxDataPoints?: number;
children: (r: RenderProps) => JSX.Element;
onDataResponse?: (data: DataQueryResponse) => void;
onError: (message: string, error: DataQueryError) => void;
}
export interface State {
isFirstLoad: boolean;
loading: LoadingState;
errorMessage: string;
response: DataQueryResponse;
}
@@ -61,7 +58,6 @@ export class DataPanel extends Component<Props, State> {
this.state = {
loading: LoadingState.NotStarted,
errorMessage: '',
response: {
data: [],
},
@@ -100,6 +96,7 @@ export class DataPanel extends Component<Props, State> {
widthPixels,
maxDataPoints,
onDataResponse,
onError,
} = this.props;
if (!isVisible) {
@@ -111,7 +108,7 @@ export class DataPanel extends Component<Props, State> {
return;
}
this.setState({ loading: LoadingState.Loading, errorMessage: '' });
this.setState({ loading: LoadingState.Loading });
try {
const ds = await this.dataSourceSrv.get(datasource);
@@ -150,18 +147,22 @@ export class DataPanel extends Component<Props, State> {
isFirstLoad: false,
});
} catch (err) {
console.log('Loading error', err);
this.onError('Request Error');
}
};
console.log('DataPanel error', err);
onError = (errorMessage: string) => {
if (this.state.loading !== LoadingState.Error || this.state.errorMessage !== errorMessage) {
this.setState({
loading: LoadingState.Error,
isFirstLoad: false,
errorMessage: errorMessage,
});
let message = 'Query error';
if (err.message) {
message = err.message;
} else if (err.data && err.data.message) {
message = err.data.message;
} else if (err.data && err.data.error) {
message = err.data.error;
} else if (err.status) {
message = `Query error: ${err.status} ${err.statusText}`;
}
onError(message, err);
this.setState({ isFirstLoad: false, loading: LoadingState.Error });
}
};
@@ -184,11 +185,11 @@ export class DataPanel extends Component<Props, State> {
render() {
const { queries } = this.props;
const { loading, isFirstLoad } = this.state;
const panelData = this.getPanelData();
if (isFirstLoad && loading === LoadingState.Loading) {
return this.renderLoadingStates();
// do not render component until we have first data
if (isFirstLoad && (loading === LoadingState.Loading || loading === LoadingState.NotStarted)) {
return this.renderLoadingState();
}
if (!queries.length) {
@@ -201,46 +202,17 @@ export class DataPanel extends Component<Props, State> {
return (
<>
{this.renderLoadingStates()}
<ErrorBoundary>
{({ error, errorInfo }) => {
if (errorInfo) {
this.onError(error.message || DEFAULT_PLUGIN_ERROR);
return null;
}
return (
<>
{this.props.children({
loading,
panelData,
})}
</>
);
}}
</ErrorBoundary>
{loading === LoadingState.Loading && this.renderLoadingState()}
{this.props.children({ loading, panelData })}
</>
);
}
private renderLoadingStates(): JSX.Element {
const { loading, errorMessage } = this.state;
if (loading === LoadingState.Loading) {
private renderLoadingState(): JSX.Element {
return (
<div className="panel-loading">
<i className="fa fa-spinner fa-spin" />
</div>
);
} else if (loading === LoadingState.Error) {
return (
<Tooltip content={errorMessage} placement="bottom-start" theme="error">
<div className="panel-info-corner panel-info-corner--error">
<i className="fa" />
<span className="panel-info-corner-inner" />
</div>
</Tooltip>
);
}
return null;
}
}

View File

@@ -8,6 +8,7 @@ import { getTimeSrv, TimeSrv } from '../services/TimeSrv';
// Components
import { PanelHeader } from './PanelHeader/PanelHeader';
import { DataPanel } from './DataPanel';
import ErrorBoundary from '../../../core/components/ErrorBoundary/ErrorBoundary';
// Utils
import { applyPanelTimeOverrides, snapshotDataToPanelData } from 'app/features/dashboard/utils/panel';
@@ -17,16 +18,18 @@ import { profiler } from 'app/core/profiler';
// Types
import { DashboardModel, PanelModel } from '../state';
import { PanelPlugin } from 'app/types';
import { TimeRange, LoadingState, PanelData } from '@grafana/ui';
import { DataQueryResponse, TimeRange, LoadingState, PanelData, DataQueryError } from '@grafana/ui';
import variables from 'sass/_variables.scss';
import templateSrv from 'app/features/templating/template_srv';
import { DataQueryResponse } from '@grafana/ui/src';
const DEFAULT_PLUGIN_ERROR = 'Error in plugin';
export interface Props {
panel: PanelModel;
dashboard: DashboardModel;
plugin: PanelPlugin;
isFullscreen: boolean;
}
export interface State {
@@ -34,6 +37,7 @@ export interface State {
renderCounter: number;
timeInfo?: string;
timeRange?: TimeRange;
errorMessage: string | null;
}
export class PanelChrome extends PureComponent<Props, State> {
@@ -45,6 +49,7 @@ export class PanelChrome extends PureComponent<Props, State> {
this.state = {
refreshCounter: 0,
renderCounter: 0,
errorMessage: null,
};
}
@@ -88,8 +93,33 @@ export class PanelChrome extends PureComponent<Props, State> {
if (this.props.dashboard.isSnapshot()) {
this.props.panel.snapshotData = dataQueryResponse.data;
}
// clear error state (if any)
this.clearErrorState();
// This event is used by old query editors and panel editor options
this.props.panel.events.emit('data-received', dataQueryResponse.data);
};
onDataError = (message: string, error: DataQueryError) => {
if (this.state.errorMessage !== message) {
this.setState({ errorMessage: message });
}
// this event is used by old query editors
this.props.panel.events.emit('data-error', error);
};
onPanelError = (message: string) => {
if (this.state.errorMessage !== message) {
this.setState({ errorMessage: message });
}
};
clearErrorState() {
if (this.state.errorMessage) {
this.setState({ errorMessage: null });
}
}
get isVisible() {
return !this.props.dashboard.otherPanelInFullscreen(this.props.panel);
}
@@ -110,7 +140,7 @@ export class PanelChrome extends PureComponent<Props, State> {
renderPanelPlugin(loading: LoadingState, panelData: PanelData, width: number, height: number): JSX.Element {
const { panel, plugin } = this.props;
const { timeRange, renderCounter } = this.state;
const PanelComponent = plugin.exports.Panel;
const PanelComponent = plugin.exports.reactPanel.panel;
// This is only done to increase a counter that is used by backend
// image rendering (phantomjs/headless chrome) to know when to capture image
@@ -124,9 +154,9 @@ export class PanelChrome extends PureComponent<Props, State> {
loading={loading}
panelData={panelData}
timeRange={timeRange}
options={panel.getOptions(plugin.exports.PanelDefaults)}
width={width - 2 * variables.panelHorizontalPadding}
height={height - PANEL_HEADER_HEIGHT - variables.panelVerticalPadding}
options={panel.getOptions(plugin.exports.reactPanel.defaults)}
width={width - 2 * variables.panelhorizontalpadding}
height={height - PANEL_HEADER_HEIGHT - variables.panelverticalpadding}
renderCounter={renderCounter}
onInterpolate={this.onInterpolate}
/>
@@ -150,6 +180,7 @@ export class PanelChrome extends PureComponent<Props, State> {
widthPixels={width}
refreshCounter={refreshCounter}
onDataResponse={this.onDataResponse}
onError={this.onDataError}
>
{({ loading, panelData }) => {
return this.renderPanelPlugin(loading, panelData, width, height);
@@ -163,8 +194,8 @@ export class PanelChrome extends PureComponent<Props, State> {
};
render() {
const { dashboard, panel } = this.props;
const { timeInfo } = this.state;
const { dashboard, panel, isFullscreen } = this.props;
const { errorMessage, timeInfo } = this.state;
const { transparent } = panel;
const containerClassNames = `panel-container panel-container--absolute ${transparent ? 'panel-transparent' : ''}`;
@@ -185,8 +216,18 @@ export class PanelChrome extends PureComponent<Props, State> {
description={panel.description}
scopedVars={panel.scopedVars}
links={panel.links}
error={errorMessage}
isFullscreen={isFullscreen}
/>
{this.renderPanelBody(width, height)}
<ErrorBoundary>
{({ error, errorInfo }) => {
if (errorInfo) {
this.onPanelError(error.message || DEFAULT_PLUGIN_ERROR);
return null;
}
return this.renderPanelBody(width, height);
}}
</ErrorBoundary>
</div>
);
}}

View File

@@ -2,7 +2,7 @@ import React, { Component } from 'react';
import classNames from 'classnames';
import { isEqual } from 'lodash';
import PanelHeaderCorner from './PanelHeaderCorner';
import { PanelHeaderCorner } from './PanelHeaderCorner';
import { PanelHeaderMenu } from './PanelHeaderMenu';
import templateSrv from 'app/features/templating/template_srv';
@@ -18,6 +18,8 @@ export interface Props {
description?: string;
scopedVars?: string;
links?: [];
error?: string;
isFullscreen: boolean;
}
interface ClickCoordinates {
@@ -68,10 +70,9 @@ export class PanelHeader extends Component<Props, State> {
};
render() {
const isFullscreen = false;
const isLoading = false;
const { panel, dashboard, timeInfo, scopedVars, error, isFullscreen } = this.props;
const panelHeaderClass = classNames({ 'panel-header': true, 'grid-drag-handle': !isFullscreen });
const { panel, dashboard, timeInfo, scopedVars } = this.props;
const title = templateSrv.replaceWithText(panel.title, scopedVars);
return (
@@ -82,13 +83,9 @@ export class PanelHeader extends Component<Props, State> {
description={panel.description}
scopedVars={panel.scopedVars}
links={panel.links}
error={error}
/>
<div className={panelHeaderClass}>
{isLoading && (
<span className="panel-loading">
<i className="fa fa-spinner fa-spin" />
</span>
)}
<div className="panel-title-container" onClick={this.onMenuToggle} onMouseDown={this.onMouseDown}>
<div className="panel-title">
<span className="icon-gf panel-alert-icon" />

View File

@@ -6,7 +6,7 @@ import templateSrv from 'app/features/templating/template_srv';
import { LinkSrv } from 'app/features/panel/panellinks/link_srv';
import { getTimeSrv, TimeSrv } from 'app/features/dashboard/services/TimeSrv';
enum InfoModes {
enum InfoMode {
Error = 'Error',
Info = 'Info',
Links = 'Links',
@@ -18,18 +18,22 @@ interface Props {
description?: string;
scopedVars?: string;
links?: [];
error?: string;
}
export class PanelHeaderCorner extends Component<Props> {
timeSrv: TimeSrv = getTimeSrv();
getInfoMode = () => {
const { panel } = this.props;
const { panel, error } = this.props;
if (error) {
return InfoMode.Error;
}
if (!!panel.description) {
return InfoModes.Info;
return InfoMode.Info;
}
if (panel.links && panel.links.length) {
return InfoModes.Links;
return InfoMode.Links;
}
return undefined;
@@ -42,11 +46,10 @@ export class PanelHeaderCorner extends Component<Props> {
const interpolatedMarkdown = templateSrv.replace(markdown, panel.scopedVars);
const remarkableInterpolatedMarkdown = new Remarkable().render(interpolatedMarkdown);
const html = (
return (
<div className="markdown-html">
<div dangerouslySetInnerHTML={{ __html: remarkableInterpolatedMarkdown }} />
{panel.links &&
panel.links.length > 0 && (
{panel.links && panel.links.length > 0 && (
<ul className="text-left">
{panel.links.map((link, idx) => {
const info = linkSrv.getPanelLinkAnchorInfo(link, panel.scopedVars);
@@ -62,30 +65,35 @@ export class PanelHeaderCorner extends Component<Props> {
)}
</div>
);
return html;
};
render() {
const infoMode: InfoModes | undefined = this.getInfoMode();
if (!infoMode) {
return null;
}
renderCornerType(infoMode: InfoMode, content: string | JSX.Element) {
const theme = infoMode === InfoMode.Error ? 'error' : 'info';
return (
<>
{infoMode === InfoModes.Info || infoMode === InfoModes.Links ? (
<Tooltip content={this.getInfoContent()} placement="bottom-start">
<Tooltip content={content} placement="bottom-start" theme={theme}>
<div className={`panel-info-corner panel-info-corner--${infoMode.toLowerCase()}`}>
<i className="fa" />
<span className="panel-info-corner-inner" />
</div>
</Tooltip>
) : null}
</>
);
}
render() {
const infoMode: InfoMode | undefined = this.getInfoMode();
if (!infoMode) {
return null;
}
export default PanelHeaderCorner;
if (infoMode === InfoMode.Error) {
return this.renderCornerType(infoMode, this.props.error);
}
if (infoMode === InfoMode.Info) {
return this.renderCornerType(infoMode, this.getInfoContent());
}
return null;
}
}

View File

@@ -3,7 +3,7 @@ import _ from 'lodash';
import React, { PureComponent } from 'react';
// Types
import { PanelProps } from '@grafana/ui';
import { PanelProps, ReactPanelPlugin } from '@grafana/ui';
import { PanelPlugin } from 'app/types';
interface Props {
@@ -63,7 +63,7 @@ export function getPanelPluginNotFound(id: string): PanelPlugin {
},
exports: {
Panel: NotFound,
reactPanel: new ReactPanelPlugin(NotFound),
},
};
}

View File

@@ -1,16 +1,17 @@
import React, { FC } from 'react';
import React, { FC, ChangeEvent } from 'react';
import { FormLabel } from '@grafana/ui';
interface Props {
label: string;
placeholder?: string;
name?: string;
value?: string;
onChange?: (evt: any) => void;
name: string;
value: string;
onBlur: (event: ChangeEvent<HTMLInputElement>) => void;
onChange: (event: ChangeEvent<HTMLInputElement>) => void;
tooltipInfo?: any;
}
export const DataSourceOptions: FC<Props> = ({ label, placeholder, name, value, onChange, tooltipInfo }) => {
export const DataSourceOption: FC<Props> = ({ label, placeholder, name, value, onBlur, onChange, tooltipInfo }) => {
return (
<div className="gf-form gf-form--flex-end">
<FormLabel tooltip={tooltipInfo}>{label}</FormLabel>
@@ -20,10 +21,10 @@ export const DataSourceOptions: FC<Props> = ({ label, placeholder, name, value,
placeholder={placeholder}
name={name}
spellCheck={false}
onBlur={evt => onChange(evt.target.value)}
onBlur={onBlur}
onChange={onChange}
value={value}
/>
</div>
);
};
export default DataSourceOptions;

View File

@@ -118,7 +118,7 @@ export class EditorTabBody extends PureComponent<Props, State> {
{toolbarItems.map(item => this.renderButton(item))}
</div>
<div className="panel-editor__scroll">
<CustomScrollbar autoHide={false} scrollTop={scrollTop} setScrollTop={setScrollTop}>
<CustomScrollbar autoHide={false} scrollTop={scrollTop} setScrollTop={setScrollTop} updateAfterMountMs={300}>
<div className="panel-editor__content">
<FadeIn in={isOpen} duration={200} unmountOnExit={true}>
{openView && this.renderOpenView(openView)}

View File

@@ -44,7 +44,7 @@ export class GeneralTab extends PureComponent<Props> {
render() {
return (
<EditorTabBody heading="Panel Options" toolbarItems={[]}>
<EditorTabBody heading="General" toolbarItems={[]}>
<div ref={element => (this.element = element)} />
</EditorTabBody>
);

View File

@@ -45,7 +45,7 @@ interface PanelEditorTab {
const panelEditorTabTexts = {
[PanelEditorTabIds.Queries]: 'Queries',
[PanelEditorTabIds.Visualization]: 'Visualization',
[PanelEditorTabIds.Advanced]: 'Panel Options',
[PanelEditorTabIds.Advanced]: 'General',
[PanelEditorTabIds.Alert]: 'Alert',
};

View File

@@ -135,7 +135,7 @@ export class QueriesTab extends PureComponent<Props, State> {
<DataSourcePicker datasources={this.datasources} onChange={this.onChangeDataSource} current={currentDS} />
<div className="flex-grow-1" />
{!isAddingMixed && (
<button className="btn navbar-button navbar-button--primary" onClick={this.onAddQueryClick}>
<button className="btn navbar-button" onClick={this.onAddQueryClick}>
Add Query
</button>
)}
@@ -176,7 +176,7 @@ export class QueriesTab extends PureComponent<Props, State> {
};
render() {
const { panel } = this.props;
const { panel, dashboard } = this.props;
const { currentDS, scrollTop } = this.state;
const queryInspector: EditorToolbarView = {
@@ -205,6 +205,7 @@ export class QueriesTab extends PureComponent<Props, State> {
dataSourceValue={query.datasource || panel.datasource}
key={query.refId}
panel={panel}
dashboard={dashboard}
query={query}
onChange={query => this.onQueryChange(query, index)}
onRemoveQuery={this.onRemoveQuery}

View File

@@ -12,10 +12,12 @@ import { getTimeSrv } from 'app/features/dashboard/services/TimeSrv';
// Types
import { PanelModel } from '../state/PanelModel';
import { DataQuery, DataSourceApi, TimeRange } from '@grafana/ui';
import { DashboardModel } from '../state/DashboardModel';
interface Props {
panel: PanelModel;
query: DataQuery;
dashboard: DashboardModel;
onAddQuery: (query?: DataQuery) => void;
onRemoveQuery: (query: DataQuery) => void;
onMoveQuery: (query: DataQuery, direction: number) => void;
@@ -28,39 +30,69 @@ interface State {
loadedDataSourceValue: string | null | undefined;
datasource: DataSourceApi | null;
isCollapsed: boolean;
angularScope: AngularQueryComponentScope | null;
hasTextEditMode: boolean;
}
export class QueryEditorRow extends PureComponent<Props, State> {
element: HTMLElement | null = null;
angularScope: AngularQueryComponentScope | null;
angularQueryEditor: AngularComponent | null = null;
state: State = {
datasource: null,
isCollapsed: false,
angularScope: null,
loadedDataSourceValue: undefined,
hasTextEditMode: false,
};
componentDidMount() {
this.loadDatasource();
this.props.panel.events.on('refresh', this.onPanelRefresh);
this.props.panel.events.on('data-error', this.onPanelDataError);
this.props.panel.events.on('data-received', this.onPanelDataReceived);
}
componentWillUnmount() {
this.props.panel.events.off('refresh', this.onPanelRefresh);
this.props.panel.events.off('data-error', this.onPanelDataError);
this.props.panel.events.off('data-received', this.onPanelDataReceived);
if (this.angularQueryEditor) {
this.angularQueryEditor.destroy();
}
}
onPanelDataError = () => {
// Some query controllers listen to data error events and need a digest
if (this.angularQueryEditor) {
// for some reason this needs to be done in next tick
setTimeout(this.angularQueryEditor.digest);
}
};
onPanelDataReceived = () => {
// Some query controllers listen to data error events and need a digest
if (this.angularQueryEditor) {
// for some reason this needs to be done in next tick
setTimeout(this.angularQueryEditor.digest);
}
};
onPanelRefresh = () => {
if (this.state.angularScope) {
this.state.angularScope.range = getTimeSrv().timeRange();
if (this.angularScope) {
this.angularScope.range = getTimeSrv().timeRange();
}
};
getAngularQueryComponentScope(): AngularQueryComponentScope {
const { panel, query } = this.props;
const { panel, query, dashboard } = this.props;
const { datasource } = this.state;
return {
datasource: datasource,
target: query,
panel: panel,
dashboard: dashboard,
refresh: () => panel.refresh(),
render: () => panel.render(),
events: panel.events,
@@ -73,7 +105,11 @@ export class QueryEditorRow extends PureComponent<Props, State> {
const dataSourceSrv = getDatasourceSrv();
const datasource = await dataSourceSrv.get(query.datasource || panel.datasource);
this.setState({ datasource, loadedDataSourceValue: this.props.dataSourceValue });
this.setState({
datasource,
loadedDataSourceValue: this.props.dataSourceValue,
hasTextEditMode: false,
});
}
componentDidUpdate() {
@@ -98,21 +134,14 @@ export class QueryEditorRow extends PureComponent<Props, State> {
const scopeProps = { ctrl: this.getAngularQueryComponentScope() };
this.angularQueryEditor = loader.load(this.element, scopeProps, template);
this.angularScope = scopeProps.ctrl;
// give angular time to compile
setTimeout(() => {
this.setState({ angularScope: scopeProps.ctrl });
this.setState({ hasTextEditMode: !!this.angularScope.toggleEditorMode });
}, 10);
}
componentWillUnmount() {
this.props.panel.events.off('refresh', this.onPanelRefresh);
if (this.angularQueryEditor) {
this.angularQueryEditor.destroy();
}
}
onToggleCollapse = () => {
this.setState({ isCollapsed: !this.state.isCollapsed });
};
@@ -138,10 +167,8 @@ export class QueryEditorRow extends PureComponent<Props, State> {
}
onToggleEditMode = () => {
const { angularScope } = this.state;
if (angularScope && angularScope.toggleEditorMode) {
angularScope.toggleEditorMode();
if (this.angularScope && this.angularScope.toggleEditorMode) {
this.angularScope.toggleEditorMode();
this.angularQueryEditor.digest();
}
@@ -150,11 +177,6 @@ export class QueryEditorRow extends PureComponent<Props, State> {
}
};
get hasTextEditMode() {
const { angularScope } = this.state;
return angularScope && angularScope.toggleEditorMode;
}
onRemoveQuery = () => {
this.props.onRemoveQuery(this.props.query);
};
@@ -171,10 +193,8 @@ export class QueryEditorRow extends PureComponent<Props, State> {
};
renderCollapsedText(): string | null {
const { angularScope } = this.state;
if (angularScope && angularScope.getCollapsedText) {
return angularScope.getCollapsedText();
if (this.angularScope && this.angularScope.getCollapsedText) {
return this.angularScope.getCollapsedText();
}
return null;
@@ -182,7 +202,7 @@ export class QueryEditorRow extends PureComponent<Props, State> {
render() {
const { query, inMixedMode } = this.props;
const { datasource, isCollapsed } = this.state;
const { datasource, isCollapsed, hasTextEditMode } = this.state;
const isDisabled = query.hide;
const bodyClasses = classNames('query-editor-row__body gf-form-query', {
@@ -212,7 +232,7 @@ export class QueryEditorRow extends PureComponent<Props, State> {
{isCollapsed && <div>{this.renderCollapsedText()}</div>}
</div>
<div className="query-editor-row__actions">
{this.hasTextEditMode && (
{hasTextEditMode && (
<button
className="query-editor-row__action"
onClick={this.onToggleEditMode}
@@ -248,6 +268,7 @@ export class QueryEditorRow extends PureComponent<Props, State> {
export interface AngularQueryComponentScope {
target: DataQuery;
panel: PanelModel;
dashboard: DashboardModel;
events: Emitter;
refresh: () => void;
render: () => void;

View File

@@ -1,5 +1,5 @@
// Libraries
import React, { PureComponent } from 'react';
import React, { PureComponent, ChangeEvent, FocusEvent } from 'react';
// Utils
import { isValidTimeSpan } from 'app/core/utils/rangeutil';
@@ -9,7 +9,7 @@ import { Switch } from '@grafana/ui';
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 { DataSourceOption } from './DataSourceOption';
import { FormLabel } from '@grafana/ui';
// Types
@@ -43,77 +43,18 @@ interface Props {
interface State {
relativeTime: string;
timeShift: string;
cacheTimeout: string;
maxDataPoints: string;
interval: string;
hideTimeOverride: boolean;
}
export class QueryOptions extends PureComponent<Props, State> {
constructor(props) {
super(props);
this.state = {
relativeTime: props.panel.timeFrom || '',
timeShift: props.panel.timeShift || '',
};
}
onRelativeTimeChange = event => {
this.setState({
relativeTime: event.target.value,
});
};
onTimeShiftChange = event => {
this.setState({
timeShift: event.target.value,
});
};
onOverrideTime = (evt, status: InputStatus) => {
const { value } = evt.target;
const { panel } = this.props;
const emptyToNullValue = emptyToNull(value);
if (status === InputStatus.Valid && panel.timeFrom !== emptyToNullValue) {
panel.timeFrom = emptyToNullValue;
panel.refresh();
}
};
onTimeShift = (evt, status: InputStatus) => {
const { value } = evt.target;
const { panel } = this.props;
const emptyToNullValue = emptyToNull(value);
if (status === InputStatus.Valid && panel.timeShift !== emptyToNullValue) {
panel.timeShift = emptyToNullValue;
panel.refresh();
}
};
onToggleTimeOverride = () => {
const { panel } = this.props;
panel.hideTimeOverride = !panel.hideTimeOverride;
panel.refresh();
};
renderOptions() {
const { datasource, panel } = this.props;
const { queryOptions } = datasource.meta;
if (!queryOptions) {
return null;
}
const onChangeFn = (panelKey: string) => {
return (value: string | number) => {
panel[panelKey] = value;
panel.refresh();
};
};
const allOptions = {
allOptions = {
cacheTimeout: {
label: 'Cache timeout',
placeholder: '60',
name: 'cacheTimeout',
value: panel.cacheTimeout,
tooltipInfo: (
<>
If your time series store has a query cache this option can override the default cache timeout. Specify a
@@ -125,7 +66,6 @@ export class QueryOptions extends PureComponent<Props, State> {
label: 'Max data points',
placeholder: 'auto',
name: 'maxDataPoints',
value: panel.maxDataPoints,
tooltipInfo: (
<>
The maximum data points the query should return. For graphs this is automatically set to one data point per
@@ -137,7 +77,6 @@ export class QueryOptions extends PureComponent<Props, State> {
label: 'Min time interval',
placeholder: '0',
name: 'minInterval',
value: panel.interval,
panelKey: 'interval',
tooltipInfo: (
<>
@@ -150,16 +89,96 @@ export class QueryOptions extends PureComponent<Props, State> {
},
};
return Object.keys(queryOptions).map(key => {
const options = allOptions[key];
return <DataSourceOption key={key} {...options} onChange={onChangeFn(allOptions[key].panelKey || key)} />;
});
constructor(props) {
super(props);
this.state = {
relativeTime: props.panel.timeFrom || '',
timeShift: props.panel.timeShift || '',
cacheTimeout: props.panel.cacheTimeout || '',
maxDataPoints: props.panel.maxDataPoints || '',
interval: props.panel.interval || '',
hideTimeOverride: props.panel.hideTimeOverride || false,
};
}
render() {
const hideTimeOverride = this.props.panel.hideTimeOverride;
const { relativeTime, timeShift } = this.state;
onRelativeTimeChange = (event: ChangeEvent<HTMLInputElement>) => {
this.setState({
relativeTime: event.target.value,
});
};
onTimeShiftChange = (event: ChangeEvent<HTMLInputElement>) => {
this.setState({
timeShift: event.target.value,
});
};
onOverrideTime = (event: FocusEvent<HTMLInputElement>, status: InputStatus) => {
const { value } = event.target;
const { panel } = this.props;
const emptyToNullValue = emptyToNull(value);
if (status === InputStatus.Valid && panel.timeFrom !== emptyToNullValue) {
panel.timeFrom = emptyToNullValue;
panel.refresh();
}
};
onTimeShift = (event: FocusEvent<HTMLInputElement>, status: InputStatus) => {
const { value } = event.target;
const { panel } = this.props;
const emptyToNullValue = emptyToNull(value);
if (status === InputStatus.Valid && panel.timeShift !== emptyToNullValue) {
panel.timeShift = emptyToNullValue;
panel.refresh();
}
};
onToggleTimeOverride = () => {
const { panel } = this.props;
this.setState({ hideTimeOverride: !this.state.hideTimeOverride }, () => {
panel.hideTimeOverride = this.state.hideTimeOverride;
panel.refresh();
});
};
onDataSourceOptionBlur = (panelKey: string) => () => {
const { panel } = this.props;
panel[panelKey] = this.state[panelKey];
panel.refresh();
};
onDataSourceOptionChange = (panelKey: string) => (event: ChangeEvent<HTMLInputElement>) => {
this.setState({ ...this.state, [panelKey]: event.target.value });
};
renderOptions = () => {
const { datasource } = this.props;
const { queryOptions } = datasource.meta;
if (!queryOptions) {
return null;
}
return Object.keys(queryOptions).map(key => {
const options = this.allOptions[key];
const panelKey = options.panelKey || key;
return (
<DataSourceOption
key={key}
{...options}
onChange={this.onDataSourceOptionChange(panelKey)}
onBlur={this.onDataSourceOptionBlur(panelKey)}
value={this.state[panelKey]}
/>
);
});
};
render() {
const { hideTimeOverride } = this.state;
const { relativeTime, timeShift } = this.state;
return (
<div className="gf-form-inline">
{this.renderOptions()}

View File

@@ -14,10 +14,10 @@ import { PluginHelp } from 'app/core/components/PluginHelp/PluginHelp';
import { FadeIn } from 'app/core/components/Animations/FadeIn';
// Types
import { PanelModel } from '../state/PanelModel';
import { DashboardModel } from '../state/DashboardModel';
import { PanelModel } from '../state';
import { DashboardModel } from '../state';
import { PanelPlugin } from 'app/types/plugins';
import { FilterInput } from 'app/core/components/FilterInput/FilterInput';
import { VizPickerSearch } from './VizPickerSearch';
interface Props {
panel: PanelModel;
@@ -33,50 +33,45 @@ interface State {
isVizPickerOpen: boolean;
searchQuery: string;
scrollTop: number;
hasBeenFocused: boolean;
}
export class VisualizationTab extends PureComponent<Props, State> {
element: HTMLElement;
angularOptions: AngularComponent;
searchInput: HTMLElement;
constructor(props) {
super(props);
this.state = {
isVizPickerOpen: this.props.urlOpenVizPicker,
hasBeenFocused: false,
searchQuery: '',
scrollTop: 0,
};
}
getPanelDefaultOptions = () => {
getReactPanelOptions = () => {
const { panel, plugin } = this.props;
if (plugin.exports.PanelDefaults) {
return panel.getOptions(plugin.exports.PanelDefaults.options);
}
return panel.getOptions(plugin.exports.PanelDefaults);
return panel.getOptions(plugin.exports.reactPanel.defaults);
};
renderPanelOptions() {
const { plugin, angularPanel } = this.props;
const { PanelOptions } = plugin.exports;
if (angularPanel) {
return <div ref={element => (this.element = element)} />;
}
return (
<>
{PanelOptions ? (
<PanelOptions options={this.getPanelDefaultOptions()} onChange={this.onPanelOptionsChanged} />
) : (
<p>Visualization has no options</p>
)}
</>
);
if (plugin.exports.reactPanel) {
const PanelEditor = plugin.exports.reactPanel.editor;
if (PanelEditor) {
return <PanelEditor options={this.getReactPanelOptions()} onChange={this.onPanelOptionsChanged} />;
}
}
return <p>Visualization has no options</p>;
}
componentDidMount() {
@@ -168,7 +163,7 @@ export class VisualizationTab extends PureComponent<Props, State> {
this.props.updateLocation({ query: { openVizPicker: null }, partial: true });
}
this.setState({ isVizPickerOpen: false });
this.setState({ isVizPickerOpen: false, hasBeenFocused: false });
};
onSearchQueryChange = (value: string) => {
@@ -179,23 +174,16 @@ export class VisualizationTab extends PureComponent<Props, State> {
renderToolbar = (): JSX.Element => {
const { plugin } = this.props;
const { searchQuery } = this.state;
const { isVizPickerOpen, searchQuery } = this.state;
if (this.state.isVizPickerOpen) {
if (isVizPickerOpen) {
return (
<>
<FilterInput
labelClassName="gf-form--has-input-icon"
inputClassName="gf-form-input width-13"
placeholder=""
<VizPickerSearch
plugin={plugin}
searchQuery={searchQuery}
onChange={this.onSearchQueryChange}
value={searchQuery}
ref={elem => elem && elem.focus()}
onClose={this.onCloseVizPicker}
/>
<button className="btn btn-link toolbar__close" onClick={this.onCloseVizPicker}>
<i className="fa fa-chevron-up" />
</button>
</>
);
} else {
return (

View File

@@ -0,0 +1,33 @@
import React, { PureComponent } from 'react';
import { FilterInput } from 'app/core/components/FilterInput/FilterInput';
import { PanelPlugin } from 'app/types';
interface Props {
plugin: PanelPlugin;
searchQuery: string;
onChange: (query: string) => void;
onClose: () => void;
}
export class VizPickerSearch extends PureComponent<Props> {
render() {
const { searchQuery, onChange, onClose } = this.props;
return (
<>
<FilterInput
labelClassName="gf-form--has-input-icon"
inputClassName="gf-form-input width-13"
placeholder=""
onChange={onChange}
value={searchQuery}
ref={element => element && element.focus()}
/>
<button className="btn btn-link toolbar__close" onClick={onClose}>
<i className="fa fa-chevron-up" />
</button>
</>
);
}
}

View File

@@ -227,8 +227,8 @@ export class TimeSrv {
const timespan = range.to.valueOf() - range.from.valueOf();
const center = range.to.valueOf() - timespan / 2;
const to = center + timespan * factor / 2;
const from = center - timespan * factor / 2;
const to = center + (timespan * factor) / 2;
const from = center - (timespan * factor) / 2;
this.setTime({ from: moment.utc(from), to: moment.utc(to) });
}

View File

@@ -127,7 +127,7 @@ describe('DashboardModel', () => {
});
it('dashboard schema version should be set to latest', () => {
expect(model.schemaVersion).toBe(17);
expect(model.schemaVersion).toBe(18);
});
it('graph thresholds should be migrated', () => {

View File

@@ -22,7 +22,7 @@ export class DashboardMigrator {
let i, j, k, n;
const oldVersion = this.dashboard.schemaVersion;
const panelUpgrades = [];
this.dashboard.schemaVersion = 17;
this.dashboard.schemaVersion = 18;
if (oldVersion === this.dashboard.schemaVersion) {
return;
@@ -387,6 +387,36 @@ export class DashboardMigrator {
});
}
if (oldVersion < 18) {
// migrate change to gauge options
panelUpgrades.push(panel => {
if (panel['options-gauge']) {
panel.options = panel['options-gauge'];
panel.options.valueOptions = {
unit: panel.options.unit,
stat: panel.options.stat,
decimals: panel.options.decimals,
prefix: panel.options.prefix,
suffix: panel.options.suffix,
};
// correct order
if (panel.options.thresholds) {
panel.options.thresholds.reverse();
}
// this options prop was due to a bug
delete panel.options.options;
delete panel.options.unit;
delete panel.options.stat;
delete panel.options.decimals;
delete panel.options.prefix;
delete panel.options.suffix;
delete panel['options-gauge'];
}
});
}
if (panelUpgrades.length === 0) {
return;
}
@@ -457,7 +487,7 @@ export class DashboardMigrator {
for (const panel of row.panels) {
panel.span = panel.span || DEFAULT_PANEL_SPAN;
if (panel.minSpan) {
panel.minSpan = Math.min(GRID_COLUMN_COUNT, GRID_COLUMN_COUNT / 12 * panel.minSpan);
panel.minSpan = Math.min(GRID_COLUMN_COUNT, (GRID_COLUMN_COUNT / 12) * panel.minSpan);
}
const panelWidth = Math.floor(panel.span) * widthFactor;
const panelHeight = panel.height ? getGridHeight(panel.height) : rowGridHeight;

View File

@@ -887,8 +887,8 @@ export class DashboardModel {
}
// add back navbar height
if (kioskMode === KIOSK_MODE_TV) {
visibleHeight += 55;
if (kioskMode && kioskMode !== KIOSK_MODE_TV) {
visibleHeight += navbarHeight;
}
const visibleGridHeight = Math.floor(visibleHeight / (GRID_CELL_HEIGHT + GRID_CELL_VMARGIN));

View File

@@ -10,6 +10,20 @@ describe('PanelModel', () => {
type: 'table',
showColumns: true,
targets: [{ refId: 'A' }, { noRefId: true }],
options: {
thresholds: [
{
color: '#F2495C',
index: 1,
value: 50,
},
{
color: '#73BF69',
index: 0,
value: null,
},
],
},
});
});
@@ -35,6 +49,21 @@ describe('PanelModel', () => {
expect(saveModel.events).toBe(undefined);
});
it('should restore -Infinity value for base threshold', () => {
expect(model.options.thresholds).toEqual([
{
color: '#F2495C',
index: 1,
value: 50,
},
{
color: '#73BF69',
index: 0,
value: -Infinity,
},
]);
});
describe('when changing panel type', () => {
beforeEach(() => {
model.changeType('graph', true);
@@ -55,5 +84,19 @@ describe('PanelModel', () => {
expect(model.alert).toBe(undefined);
});
});
describe('get panel options', () => {
it('should apply defaults', () => {
model.options = { existingProp: 10 };
const options = model.getOptions({
defaultProp: true,
existingProp: 0,
});
expect(options.defaultProp).toBe(true);
expect(options.existingProp).toBe(10);
expect(model.options).toBe(options);
});
});
});
});

View File

@@ -3,8 +3,7 @@ import _ from 'lodash';
// Types
import { Emitter } from 'app/core/utils/emitter';
import { PANEL_OPTIONS_KEY_PREFIX } from 'app/core/constants';
import { DataQuery, TimeSeries } from '@grafana/ui';
import { DataQuery, TimeSeries, Threshold } from '@grafana/ui';
import { TableData } from '@grafana/ui/src';
export interface GridPos {
@@ -47,8 +46,6 @@ const mustKeepProps: { [str: string]: boolean } = {
timeFrom: true,
timeShift: true,
hideTimeOverride: true,
maxDataPoints: true,
interval: true,
description: true,
links: true,
fullscreen: true,
@@ -92,6 +89,9 @@ export class PanelModel {
timeFrom?: any;
timeShift?: any;
hideTimeOverride?: any;
options: {
[key: string]: any;
};
maxDataPoints?: number;
interval?: string;
@@ -105,8 +105,6 @@ export class PanelModel {
hasRefreshed: boolean;
events: Emitter;
cacheTimeout?: any;
// cache props between plugins
cachedPluginOptions?: any;
constructor(model) {
@@ -121,6 +119,8 @@ export class PanelModel {
_.defaultsDeep(this, _.cloneDeep(defaults));
// queries must have refId
this.ensureQueryIds();
this.restoreInfintyForThresholds();
}
ensureQueryIds() {
@@ -133,21 +133,28 @@ export class PanelModel {
}
}
restoreInfintyForThresholds() {
if (this.options && this.options.thresholds) {
this.options.thresholds = this.options.thresholds.map((threshold: Threshold) => {
// JSON serialization of -Infinity is 'null' so lets convert it back to -Infinity
if (threshold.index === 0 && threshold.value === null) {
return { ...threshold, value: -Infinity };
}
return threshold;
});
}
}
getOptions(panelDefaults) {
return _.defaultsDeep(this[this.getOptionsKey()] || {}, panelDefaults);
return _.defaultsDeep(this.options || {}, panelDefaults);
}
updateOptions(options: object) {
const update: any = {};
update[this.getOptionsKey()] = options;
Object.assign(this, update);
this.options = options;
this.render();
}
private getOptionsKey() {
return PANEL_OPTIONS_KEY_PREFIX + this.type;
}
getSaveModel() {
const model: any = {};
for (const property in this) {
@@ -240,7 +247,9 @@ export class PanelModel {
// for angular panels only we need to remove all events and let angular panels do some cleanup
if (fromAngularPanel) {
this.destroy();
}
// remove panel type specific options
for (const key of _.keys(this)) {
if (mustKeepProps[key]) {
continue;
@@ -248,7 +257,6 @@ export class PanelModel {
delete this[key];
}
}
this.restorePanelOptions(pluginId);
}

View File

@@ -70,6 +70,7 @@ export const editPanelJson = (dashboard: DashboardModel, panel: PanelModel) => {
updateHandler: (newPanel: PanelModel, oldPanel: PanelModel) => {
replacePanel(dashboard, newPanel, oldPanel);
},
canUpdate: dashboard.meta.canEdit,
enableCopy: true,
};

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