mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
azuremonitor: fixes merge conflict in yarn.lock
This commit is contained in:
commit
a3e09d8bbc
@ -5,6 +5,7 @@
|
||||
* **Elasticsearch**: Support bucket script pipeline aggregations [#5968](https://github.com/grafana/grafana/issues/5968)
|
||||
* **Influxdb**: Add support for time zone (`tz`) clause [#10322](https://github.com/grafana/grafana/issues/10322), thx [@cykl](https://github.com/cykl)
|
||||
* **Snapshots**: Enable deletion of public snapshot [#14109](https://github.com/grafana/grafana/issues/14109)
|
||||
* **Provisioning**: Provisioning support for alert notifiers [#10487](https://github.com/grafana/grafana/issues/10487), thx [@pbakulev](https://github.com/pbakulev)
|
||||
|
||||
### Minor
|
||||
|
||||
@ -15,6 +16,7 @@
|
||||
* **Influxdb**: Fix autocomplete of measurements does not escape search string properly [#11503](https://github.com/grafana/grafana/issues/11503), thx [@SamuelToh](https://github.com/SamuelToh)
|
||||
* **Stackdriver**: Aggregating series returns more than one series [#14581](https://github.com/grafana/grafana/issues/14581) and [#13914](https://github.com/grafana/grafana/issues/13914), thx [@kinok](https://github.com/kinok)
|
||||
* **Cloudwatch**: Fix Assume Role Arn [#14722](https://github.com/grafana/grafana/issues/14722), thx [@jaken551](https://github.com/jaken551)
|
||||
* **Postgres/MySQL/MSSQL**: Nanosecond timestamp support (`$__unixEpochNanoFilter`, `$__unixEpochNanoFrom`, `$__unixEpochNanoTo`) [#14711](https://github.com/grafana/grafana/pull/14711), thx [@ander26](https://github.com/ander26)
|
||||
* **Provisioning**: Fixes bug causing infinite growth in dashboard_version table. [#12864](https://github.com/grafana/grafana/issues/12864)
|
||||
* **Auth**: Prevent password reset when login form is disabled or either LDAP or Auth Proxy is enabled [#14246](https://github.com/grafana/grafana/issues/14246), thx [@SilverFire](https://github.com/SilverFire)
|
||||
* **Admin**: Fix prevent removing last grafana admin permissions [#11067](https://github.com/grafana/grafana/issues/11067), thx [@danielbh](https://github.com/danielbh)
|
||||
|
25
conf/provisioning/notifiers/sample.yaml
Normal file
25
conf/provisioning/notifiers/sample.yaml
Normal file
@ -0,0 +1,25 @@
|
||||
# # config file version
|
||||
apiVersion: 1
|
||||
|
||||
# notifiers:
|
||||
# - name: default-slack-temp
|
||||
# type: slack
|
||||
# org_name: Main Org.
|
||||
# is_default: true
|
||||
# uid: notifier1
|
||||
# settings:
|
||||
# recipient: "XXX"
|
||||
# token: "xoxb"
|
||||
# uploadImage: true
|
||||
# url: https://slack.com
|
||||
# - name: default-email
|
||||
# type: email
|
||||
# org_id: 1
|
||||
# uid: notifier2
|
||||
# is_default: false
|
||||
# settings:
|
||||
# addresses: example11111@example.com
|
||||
# delete_notifiers:
|
||||
# - name: default-slack-temp
|
||||
# org_name: Main Org.
|
||||
# uid: notifier1
|
@ -152,4 +152,10 @@ datasources:
|
||||
authType: credentials
|
||||
defaultRegion: eu-west-2
|
||||
|
||||
- name: gdev-loki
|
||||
type: loki
|
||||
access: proxy
|
||||
url: http://localhost:3100
|
||||
editable: false
|
||||
|
||||
|
||||
|
22
devenv/docker/blocks/loki/docker-compose.yaml
Normal file
22
devenv/docker/blocks/loki/docker-compose.yaml
Normal file
@ -0,0 +1,22 @@
|
||||
version: "3"
|
||||
|
||||
networks:
|
||||
loki:
|
||||
|
||||
services:
|
||||
loki:
|
||||
image: grafana/loki:master
|
||||
ports:
|
||||
- "3100:3100"
|
||||
command: -config.file=/etc/loki/local-config.yaml
|
||||
networks:
|
||||
- loki
|
||||
|
||||
promtail:
|
||||
image: grafana/promtail:master
|
||||
volumes:
|
||||
- /var/log:/var/log
|
||||
command:
|
||||
-config.file=/etc/promtail/docker-config.yaml
|
||||
networks:
|
||||
- loki
|
@ -231,3 +231,187 @@ By default Grafana will delete dashboards in the database if the file is removed
|
||||
> which leads to problems if you re-use settings that are supposed to be unique.
|
||||
> Be careful not to re-use the same `title` multiple times within a folder
|
||||
> or `uid` within the same installation as this will cause weird behaviors.
|
||||
|
||||
## Alert Notification Channels
|
||||
|
||||
Alert Notification Channels can be provisioned by adding one or more yaml config files in the [`provisioning/notifiers`](/installation/configuration/#provisioning) directory.
|
||||
|
||||
Each config file can contain the following top-level fields:
|
||||
- `notifiers`, a list of alert notifications that will be added or updated during start up. If the notification channel already exists, Grafana will update it to match the configuration file.
|
||||
- `delete_notifiers`, a list of alert notifications to be deleted before before inserting/updating those in the `notifiers` list.
|
||||
|
||||
Provisioning looks up alert notifications by uid, and will update any existing notification with the provided uid.
|
||||
|
||||
By default, exporting a dashboard as JSON will use a sequential identifier to refer to alert notifications. The field `uid` can be optionally specified to specify a string identifier for the alert name.
|
||||
|
||||
```json
|
||||
{
|
||||
...
|
||||
"alert": {
|
||||
...,
|
||||
"conditions": [...],
|
||||
"frequency": "24h",
|
||||
"noDataState": "ok",
|
||||
"notifications": [
|
||||
{"uid": "notifier1"},
|
||||
{"uid": "notifier2"},
|
||||
]
|
||||
}
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
### Example Alert Notification Channels Config File
|
||||
|
||||
```yaml
|
||||
notifiers:
|
||||
- name: notification-channel-1
|
||||
type: slack
|
||||
uid: notifier1
|
||||
# either
|
||||
org_id: 2
|
||||
# or
|
||||
org_name: Main Org.
|
||||
is_default: true
|
||||
# See `Supported Settings` section for settings supporter for each
|
||||
# alert notification type.
|
||||
settings:
|
||||
recipient: "XXX"
|
||||
token: "xoxb"
|
||||
uploadImage: true
|
||||
url: https://slack.com
|
||||
|
||||
delete_notifiers:
|
||||
- name: notification-channel-1
|
||||
uid: notifier1
|
||||
# either
|
||||
org_id: 2
|
||||
# or
|
||||
org_name: Main Org.
|
||||
- name: notification-channel-2
|
||||
# default org_id: 1
|
||||
```
|
||||
|
||||
### Supported Settings
|
||||
|
||||
The following sections detail the supported settings for each alert notification type.
|
||||
|
||||
#### Alert notification `pushover`
|
||||
|
||||
| Name |
|
||||
| ---- |
|
||||
| apiToken |
|
||||
| userKey |
|
||||
| device |
|
||||
| retry |
|
||||
| expire |
|
||||
|
||||
#### Alert notification `slack`
|
||||
|
||||
| Name |
|
||||
| ---- |
|
||||
| url |
|
||||
| recipient |
|
||||
| username |
|
||||
| iconEmoji |
|
||||
| iconUrl |
|
||||
| uploadImage |
|
||||
| mention |
|
||||
| token |
|
||||
|
||||
#### Alert notification `victorops`
|
||||
|
||||
| Name |
|
||||
| ---- |
|
||||
| url |
|
||||
|
||||
#### Alert notification `kafka`
|
||||
|
||||
| Name |
|
||||
| ---- |
|
||||
| kafkaRestProxy |
|
||||
| kafkaTopic |
|
||||
|
||||
#### Alert notification `LINE`
|
||||
|
||||
| Name |
|
||||
| ---- |
|
||||
| token |
|
||||
|
||||
#### Alert notification `pagerduty`
|
||||
|
||||
| Name |
|
||||
| ---- |
|
||||
| integrationKey |
|
||||
|
||||
#### Alert notification `sensu`
|
||||
|
||||
| Name |
|
||||
| ---- |
|
||||
| url |
|
||||
| source |
|
||||
| handler |
|
||||
| username |
|
||||
| password |
|
||||
|
||||
#### Alert notification `prometheus-alertmanager`
|
||||
|
||||
| Name |
|
||||
| ---- |
|
||||
| url |
|
||||
|
||||
#### Alert notification `teams`
|
||||
|
||||
| Name |
|
||||
| ---- |
|
||||
| url |
|
||||
|
||||
#### Alert notification `dingding`
|
||||
|
||||
| Name |
|
||||
| ---- |
|
||||
| url |
|
||||
|
||||
#### Alert notification `email`
|
||||
|
||||
| Name |
|
||||
| ---- |
|
||||
| addresses |
|
||||
|
||||
#### Alert notification `hipchat`
|
||||
|
||||
| Name |
|
||||
| ---- |
|
||||
| url |
|
||||
| apikey |
|
||||
| roomid |
|
||||
|
||||
#### Alert notification `opsgenie`
|
||||
|
||||
| Name |
|
||||
| ---- |
|
||||
| apiKey |
|
||||
| apiUrl |
|
||||
|
||||
#### Alert notification `telegram`
|
||||
|
||||
| Name |
|
||||
| ---- |
|
||||
| bottoken |
|
||||
| chatid |
|
||||
|
||||
#### Alert notification `threema`
|
||||
|
||||
| Name |
|
||||
| ---- |
|
||||
| gateway_id |
|
||||
| recipient_id |
|
||||
| api_secret |
|
||||
|
||||
#### Alert notification `webhook`
|
||||
|
||||
| Name |
|
||||
| ---- |
|
||||
| url |
|
||||
| username |
|
||||
| password |
|
@ -110,6 +110,9 @@ Macro example | Description
|
||||
*$__unixEpochFilter(dateColumn)* | Will be replaced by a time range filter using the specified column name with times represented as unix timestamp. For example, *dateColumn > 1494410783 AND dateColumn < 1494497183*
|
||||
*$__unixEpochFrom()* | Will be replaced by the start of the currently active time selection as unix timestamp. For example, *1494410783*
|
||||
*$__unixEpochTo()* | Will be replaced by the end of the currently active time selection as unix timestamp. For example, *1494497183*
|
||||
*$__unixEpochNanoFilter(dateColumn)* | Will be replaced by a time range filter using the specified column name with times represented as nanosecond timestamp. For example, *dateColumn > 1494410783152415214 AND dateColumn < 1494497183142514872*
|
||||
*$__unixEpochNanoFrom()* | Will be replaced by the start of the currently active time selection as nanosecond timestamp. For example, *1494410783152415214*
|
||||
*$__unixEpochNanoTo()* | Will be replaced by the end of the currently active time selection as nanosecond timestamp. For example, *1494497183142514872*
|
||||
*$__unixEpochGroup(dateColumn,'5m', [fillmode])* | Same as $__timeGroup but for times stored as unix timestamp (only available in Grafana 5.3+).
|
||||
*$__unixEpochGroupAlias(dateColumn,'5m', [fillmode])* | Same as above but also adds a column alias (only available in Grafana 5.3+).
|
||||
|
||||
|
@ -144,6 +144,9 @@ Macro example | Description
|
||||
*$__unixEpochFilter(dateColumn)* | Will be replaced by a time range filter using the specified column name with times represented as unix timestamp. For example, *dateColumn > 1494410783 AND dateColumn < 1494497183*
|
||||
*$__unixEpochFrom()* | Will be replaced by the start of the currently active time selection as unix timestamp. For example, *1494410783*
|
||||
*$__unixEpochTo()* | Will be replaced by the end of the currently active time selection as unix timestamp. For example, *1494497183*
|
||||
*$__unixEpochNanoFilter(dateColumn)* | Will be replaced by a time range filter using the specified column name with times represented as nanosecond timestamp. For example, *dateColumn > 1494410783152415214 AND dateColumn < 1494497183142514872*
|
||||
*$__unixEpochNanoFrom()* | Will be replaced by the start of the currently active time selection as nanosecond timestamp. For example, *1494410783152415214*
|
||||
*$__unixEpochNanoTo()* | Will be replaced by the end of the currently active time selection as nanosecond timestamp. For example, *1494497183142514872*
|
||||
*$__unixEpochGroup(dateColumn,'5m', [fillmode])* | Same as $__timeGroup but for times stored as unix timestamp (only available in Grafana 5.3+).
|
||||
*$__unixEpochGroupAlias(dateColumn,'5m', [fillmode])* | Same as above but also adds a column alias (only available in Grafana 5.3+).
|
||||
|
||||
|
@ -154,6 +154,9 @@ Macro example | Description
|
||||
*$__unixEpochFilter(dateColumn)* | Will be replaced by a time range filter using the specified column name with times represented as unix timestamps. For example, *dateColumn >= 1494410783 AND dateColumn <= 1494497183*
|
||||
*$__unixEpochFrom()* | Will be replaced by the start of the currently active time selection as unix timestamp. For example, *1494410783*
|
||||
*$__unixEpochTo()* | Will be replaced by the end of the currently active time selection as unix timestamp. For example, *1494497183*
|
||||
*$__unixEpochNanoFilter(dateColumn)* | Will be replaced by a time range filter using the specified column name with times represented as nanosecond timestamps. For example, *dateColumn >= 1494410783152415214 AND dateColumn <= 1494497183142514872*
|
||||
*$__unixEpochNanoFrom()* | Will be replaced by the start of the currently active time selection as nanosecond timestamp. For example, *1494410783152415214*
|
||||
*$__unixEpochNanoTo()* | Will be replaced by the end of the currently active time selection as unix timestamp. For example, *1494497183142514872*
|
||||
*$__unixEpochGroup(dateColumn,'5m', [fillmode])* | Same as $__timeGroup, but for times stored as unix timestamp (only available in Grafana 5.3+).
|
||||
*$__unixEpochGroupAlias(dateColumn,'5m', [fillmode])* | Same as above, but also adds a column alias (only available in Grafana 5.3+).
|
||||
|
||||
|
2
packages/grafana-ui/.storybook/addons.ts
Normal file
2
packages/grafana-ui/.storybook/addons.ts
Normal file
@ -0,0 +1,2 @@
|
||||
import '@storybook/addon-knobs/register';
|
||||
import '@storybook/addon-actions/register';
|
12
packages/grafana-ui/.storybook/config.ts
Normal file
12
packages/grafana-ui/.storybook/config.ts
Normal file
@ -0,0 +1,12 @@
|
||||
import { configure } from '@storybook/react';
|
||||
|
||||
import '../../../public/sass/grafana.light.scss';
|
||||
|
||||
// automatically import all files ending in *.stories.tsx
|
||||
const req = require.context('../src/components', true, /.story.tsx$/);
|
||||
|
||||
function loadStories() {
|
||||
req.keys().forEach(req);
|
||||
}
|
||||
|
||||
configure(loadStories, module);
|
56
packages/grafana-ui/.storybook/webpack.config.js
Normal file
56
packages/grafana-ui/.storybook/webpack.config.js
Normal file
@ -0,0 +1,56 @@
|
||||
const path = require('path');
|
||||
|
||||
module.exports = (baseConfig, env, config) => {
|
||||
|
||||
config.module.rules.push({
|
||||
test: /\.(ts|tsx)$/,
|
||||
use: [
|
||||
{
|
||||
loader: require.resolve('awesome-typescript-loader'),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
config.module.rules.push({
|
||||
test: /\.scss$/,
|
||||
use: [
|
||||
{
|
||||
loader: 'style-loader',
|
||||
},
|
||||
{
|
||||
loader: 'css-loader',
|
||||
options: {
|
||||
importLoaders: 2,
|
||||
url: false,
|
||||
sourceMap: false,
|
||||
minimize: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
loader: 'postcss-loader',
|
||||
options: {
|
||||
sourceMap: false,
|
||||
config: { path: __dirname + '../../../../scripts/webpack/postcss.config.js' },
|
||||
},
|
||||
},
|
||||
{ loader: 'sass-loader', options: { sourceMap: false } },
|
||||
],
|
||||
});
|
||||
|
||||
config.module.rules.push({
|
||||
test: require.resolve('jquery'),
|
||||
use: [
|
||||
{
|
||||
loader: 'expose-loader',
|
||||
query: 'jQuery',
|
||||
},
|
||||
{
|
||||
loader: 'expose-loader',
|
||||
query: '$',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
config.resolve.extensions.push('.ts', '.tsx');
|
||||
return config;
|
||||
};
|
@ -5,19 +5,20 @@
|
||||
"main": "src/index.ts",
|
||||
"scripts": {
|
||||
"tslint": "tslint -c tslint.json --project tsconfig.json",
|
||||
"typecheck": "tsc --noEmit"
|
||||
"typecheck": "tsc --noEmit",
|
||||
"storybook": "start-storybook -p 9001 -c .storybook -s ../../public"
|
||||
},
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@torkelo/react-select": "2.1.1",
|
||||
"@types/react-test-renderer": "^16.0.3",
|
||||
"@types/react-transition-group": "^2.0.15",
|
||||
"@types/react-color": "^2.14.0",
|
||||
"classnames": "^2.2.5",
|
||||
"jquery": "^3.2.1",
|
||||
"lodash": "^4.17.10",
|
||||
"moment": "^2.22.2",
|
||||
"react": "^16.6.3",
|
||||
"react-color": "^2.17.0",
|
||||
"react-custom-scrollbars": "^4.2.1",
|
||||
"react-dom": "^16.6.3",
|
||||
"react-highlight-words": "0.11.0",
|
||||
@ -29,16 +30,32 @@
|
||||
"tinycolor2": "^1.4.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@storybook/addon-actions": "^4.1.7",
|
||||
"@storybook/addon-info": "^4.1.6",
|
||||
"@storybook/addon-knobs": "^4.1.7",
|
||||
"@storybook/react": "^4.1.4",
|
||||
"@types/classnames": "^2.2.6",
|
||||
"@types/jest": "^23.3.2",
|
||||
"@types/jquery": "^1.10.35",
|
||||
"@types/lodash": "^4.14.119",
|
||||
"@types/node": "^10.12.18",
|
||||
"@types/react": "^16.7.6",
|
||||
"@types/react-custom-scrollbars": "^4.0.5",
|
||||
"@types/react-test-renderer": "^16.0.3",
|
||||
"@types/react-transition-group": "^2.0.15",
|
||||
"@types/storybook__addon-actions": "^3.4.1",
|
||||
"@types/storybook__addon-info": "^3.4.2",
|
||||
"@types/storybook__addon-knobs": "^4.0.0",
|
||||
"@types/storybook__react": "^4.0.0",
|
||||
"@types/tether-drop": "^1.4.8",
|
||||
"@types/tinycolor2": "^1.4.1",
|
||||
"awesome-typescript-loader": "^5.2.1",
|
||||
"react-docgen-typescript-loader": "^3.0.0",
|
||||
"react-docgen-typescript-webpack-plugin": "^1.1.0",
|
||||
"react-test-renderer": "^16.7.0",
|
||||
"typescript": "^3.2.2"
|
||||
},
|
||||
"resolutions": {
|
||||
"@types/lodash": "4.14.119"
|
||||
}
|
||||
}
|
||||
|
@ -0,0 +1,94 @@
|
||||
import React from 'react';
|
||||
import { ColorPickerProps } from './ColorPicker';
|
||||
import tinycolor from 'tinycolor2';
|
||||
import { debounce } from 'lodash';
|
||||
|
||||
interface ColorInputState {
|
||||
previousColor: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
interface ColorInputProps extends ColorPickerProps {
|
||||
style?: React.CSSProperties;
|
||||
}
|
||||
|
||||
class ColorInput extends React.PureComponent<ColorInputProps, ColorInputState> {
|
||||
constructor(props: ColorInputProps) {
|
||||
super(props);
|
||||
this.state = {
|
||||
previousColor: props.color,
|
||||
value: props.color,
|
||||
};
|
||||
|
||||
this.updateColor = debounce(this.updateColor, 100);
|
||||
}
|
||||
|
||||
static getDerivedStateFromProps(props: ColorPickerProps, state: ColorInputState) {
|
||||
const newColor = tinycolor(props.color);
|
||||
if (newColor.isValid() && props.color !== state.previousColor) {
|
||||
return {
|
||||
...state,
|
||||
previousColor: props.color,
|
||||
value: newColor.toString(),
|
||||
};
|
||||
}
|
||||
|
||||
return state;
|
||||
}
|
||||
updateColor = (color: string) => {
|
||||
this.props.onChange(color);
|
||||
};
|
||||
|
||||
handleChange = (event: React.SyntheticEvent<HTMLInputElement>) => {
|
||||
const newColor = tinycolor(event.currentTarget.value);
|
||||
|
||||
this.setState({
|
||||
value: event.currentTarget.value,
|
||||
});
|
||||
|
||||
if (newColor.isValid()) {
|
||||
this.updateColor(newColor.toString());
|
||||
}
|
||||
};
|
||||
|
||||
handleBlur = () => {
|
||||
const newColor = tinycolor(this.state.value);
|
||||
|
||||
if (!newColor.isValid()) {
|
||||
this.setState({
|
||||
value: this.props.color,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
const { value } = this.state;
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
...this.props.style,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
background: this.props.color,
|
||||
width: '35px',
|
||||
height: '35px',
|
||||
flexGrow: 0,
|
||||
borderRadius: '3px 0 0 3px',
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
flexGrow: 1,
|
||||
}}
|
||||
>
|
||||
<input className="gf-form-input" value={value} onChange={this.handleChange} onBlur={this.handleBlur} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default ColorInput;
|
@ -0,0 +1,63 @@
|
||||
import React from 'react';
|
||||
import { storiesOf } from '@storybook/react';
|
||||
import { withKnobs, boolean } from '@storybook/addon-knobs';
|
||||
import { SeriesColorPicker, ColorPicker } from './ColorPicker';
|
||||
import { action } from '@storybook/addon-actions';
|
||||
import { withCenteredStory } from '../../utils/storybook/withCenteredStory';
|
||||
import { UseState } from '../../utils/storybook/UseState';
|
||||
import { getThemeKnob } from '../../utils/storybook/themeKnob';
|
||||
|
||||
const getColorPickerKnobs = () => {
|
||||
return {
|
||||
selectedTheme: getThemeKnob(),
|
||||
enableNamedColors: boolean('Enable named colors', false),
|
||||
};
|
||||
};
|
||||
|
||||
const ColorPickerStories = storiesOf('UI/ColorPicker/Pickers', module);
|
||||
|
||||
ColorPickerStories.addDecorator(withCenteredStory).addDecorator(withKnobs);
|
||||
|
||||
ColorPickerStories.add('default', () => {
|
||||
const { selectedTheme, enableNamedColors } = getColorPickerKnobs();
|
||||
return (
|
||||
<UseState initialState="#00ff00">
|
||||
{(selectedColor, updateSelectedColor) => {
|
||||
return (
|
||||
<ColorPicker
|
||||
enableNamedColors={enableNamedColors}
|
||||
color={selectedColor}
|
||||
onChange={color => {
|
||||
action('Color changed')(color);
|
||||
updateSelectedColor(color);
|
||||
}}
|
||||
theme={selectedTheme || undefined}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
</UseState>
|
||||
);
|
||||
});
|
||||
|
||||
ColorPickerStories.add('Series color picker', () => {
|
||||
const { selectedTheme, enableNamedColors } = getColorPickerKnobs();
|
||||
|
||||
return (
|
||||
<UseState initialState="#00ff00">
|
||||
{(selectedColor, updateSelectedColor) => {
|
||||
return (
|
||||
<SeriesColorPicker
|
||||
enableNamedColors={enableNamedColors}
|
||||
yaxis={1}
|
||||
onToggleAxis={() => {}}
|
||||
color={selectedColor}
|
||||
onChange={color => updateSelectedColor(color)}
|
||||
theme={selectedTheme || undefined}
|
||||
>
|
||||
<div style={{ color: selectedColor, cursor: 'pointer' }}>Open color picker</div>
|
||||
</SeriesColorPicker>
|
||||
);
|
||||
}}
|
||||
</UseState>
|
||||
);
|
||||
});
|
@ -1,61 +1,114 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import Drop from 'tether-drop';
|
||||
import React, { Component, createRef } from 'react';
|
||||
import PopperController from '../Tooltip/PopperController';
|
||||
import Popper, { RenderPopperArrowFn } from '../Tooltip/Popper';
|
||||
import { ColorPickerPopover } from './ColorPickerPopover';
|
||||
import { Themeable, GrafanaTheme } from '../../types';
|
||||
import { getColorFromHexRgbOrName } from '../../utils/namedColorsPalette';
|
||||
import { SeriesColorPickerPopover } from './SeriesColorPickerPopover';
|
||||
import propDeprecationWarning from '../../utils/propDeprecationWarning';
|
||||
|
||||
export interface Props {
|
||||
type ColorPickerChangeHandler = (color: string) => void;
|
||||
|
||||
export interface ColorPickerProps extends Themeable {
|
||||
color: string;
|
||||
onChange: (c: string) => void;
|
||||
onChange: ColorPickerChangeHandler;
|
||||
|
||||
/**
|
||||
* @deprecated Use onChange instead
|
||||
*/
|
||||
onColorChange?: ColorPickerChangeHandler;
|
||||
enableNamedColors?: boolean;
|
||||
withArrow?: boolean;
|
||||
children?: JSX.Element;
|
||||
}
|
||||
|
||||
export class ColorPicker extends React.Component<Props, any> {
|
||||
pickerElem: HTMLElement | null;
|
||||
colorPickerDrop: any;
|
||||
|
||||
openColorPicker = () => {
|
||||
const dropContent = <ColorPickerPopover color={this.props.color} onColorSelect={this.onColorSelect} />;
|
||||
|
||||
const dropContentElem = document.createElement('div');
|
||||
ReactDOM.render(dropContent, dropContentElem);
|
||||
|
||||
const drop = new Drop({
|
||||
target: this.pickerElem as Element,
|
||||
content: dropContentElem,
|
||||
position: 'top center',
|
||||
classes: 'drop-popover',
|
||||
openOn: 'click',
|
||||
hoverCloseDelay: 200,
|
||||
tetherOptions: {
|
||||
constraints: [{ to: 'scrollParent', attachment: 'none both' }],
|
||||
attachment: 'bottom center',
|
||||
},
|
||||
});
|
||||
|
||||
drop.on('close', this.closeColorPicker);
|
||||
|
||||
this.colorPickerDrop = drop;
|
||||
this.colorPickerDrop.open();
|
||||
};
|
||||
|
||||
closeColorPicker = () => {
|
||||
setTimeout(() => {
|
||||
if (this.colorPickerDrop && this.colorPickerDrop.tether) {
|
||||
this.colorPickerDrop.destroy();
|
||||
}
|
||||
}, 100);
|
||||
};
|
||||
|
||||
onColorSelect = (color: string) => {
|
||||
this.props.onChange(color);
|
||||
};
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div className="sp-replacer sp-light" onClick={this.openColorPicker} ref={element => (this.pickerElem = element)}>
|
||||
<div className="sp-preview">
|
||||
<div className="sp-preview-inner" style={{ backgroundColor: this.props.color }} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
export const warnAboutColorPickerPropsDeprecation = (componentName: string, props: ColorPickerProps) => {
|
||||
const { onColorChange } = props;
|
||||
if (onColorChange) {
|
||||
propDeprecationWarning(componentName, 'onColorChange', 'onChange');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const colorPickerFactory = <T extends ColorPickerProps>(
|
||||
popover: React.ComponentType<T>,
|
||||
displayName = 'ColorPicker',
|
||||
renderPopoverArrowFunction?: RenderPopperArrowFn
|
||||
) => {
|
||||
return class ColorPicker extends Component<T, any> {
|
||||
static displayName = displayName;
|
||||
pickerTriggerRef = createRef<HTMLDivElement>();
|
||||
|
||||
handleColorChange = (color: string) => {
|
||||
const { onColorChange, onChange } = this.props;
|
||||
const changeHandler = (onColorChange || onChange) as ColorPickerChangeHandler;
|
||||
|
||||
return changeHandler(color);
|
||||
};
|
||||
|
||||
render() {
|
||||
const popoverElement = React.createElement(popover, {
|
||||
...this.props,
|
||||
onChange: this.handleColorChange,
|
||||
});
|
||||
const { theme, withArrow, children } = this.props;
|
||||
|
||||
const renderArrow: RenderPopperArrowFn = ({ arrowProps, placement }) => {
|
||||
return (
|
||||
<div
|
||||
{...arrowProps}
|
||||
data-placement={placement}
|
||||
className={`ColorPicker__arrow ColorPicker__arrow--${theme === GrafanaTheme.Light ? 'light' : 'dark'}`}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<PopperController content={popoverElement} hideAfter={300}>
|
||||
{(showPopper, hidePopper, popperProps) => {
|
||||
return (
|
||||
<>
|
||||
{this.pickerTriggerRef.current && (
|
||||
<Popper
|
||||
{...popperProps}
|
||||
referenceElement={this.pickerTriggerRef.current}
|
||||
wrapperClassName="ColorPicker"
|
||||
renderArrow={withArrow && (renderPopoverArrowFunction || renderArrow)}
|
||||
onMouseLeave={hidePopper}
|
||||
onMouseEnter={showPopper}
|
||||
/>
|
||||
)}
|
||||
|
||||
{children ? (
|
||||
React.cloneElement(children as JSX.Element, {
|
||||
ref: this.pickerTriggerRef,
|
||||
onClick: showPopper,
|
||||
onMouseLeave: hidePopper,
|
||||
})
|
||||
) : (
|
||||
<div
|
||||
ref={this.pickerTriggerRef}
|
||||
onClick={showPopper}
|
||||
onMouseLeave={hidePopper}
|
||||
className="sp-replacer sp-light"
|
||||
>
|
||||
<div className="sp-preview">
|
||||
<div
|
||||
className="sp-preview-inner"
|
||||
style={{
|
||||
backgroundColor: getColorFromHexRgbOrName(this.props.color || '#000000', theme),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}}
|
||||
</PopperController>
|
||||
);
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
export const ColorPicker = colorPickerFactory(ColorPickerPopover, 'ColorPicker');
|
||||
export const SeriesColorPicker = colorPickerFactory(SeriesColorPickerPopover, 'SeriesColorPicker');
|
||||
|
@ -0,0 +1,40 @@
|
||||
import React from 'react';
|
||||
import { storiesOf } from '@storybook/react';
|
||||
import { ColorPickerPopover } from './ColorPickerPopover';
|
||||
import { withKnobs } from '@storybook/addon-knobs';
|
||||
|
||||
import { withCenteredStory } from '../../utils/storybook/withCenteredStory';
|
||||
import { getThemeKnob } from '../../utils/storybook/themeKnob';
|
||||
import { SeriesColorPickerPopover } from './SeriesColorPickerPopover';
|
||||
|
||||
const ColorPickerPopoverStories = storiesOf('UI/ColorPicker/Popovers', module);
|
||||
|
||||
ColorPickerPopoverStories.addDecorator(withCenteredStory).addDecorator(withKnobs);
|
||||
|
||||
ColorPickerPopoverStories.add('default', () => {
|
||||
const selectedTheme = getThemeKnob();
|
||||
|
||||
return (
|
||||
<ColorPickerPopover
|
||||
color="#BC67E6"
|
||||
onChange={color => {
|
||||
console.log(color);
|
||||
}}
|
||||
theme={selectedTheme || undefined}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
ColorPickerPopoverStories.add('SeriesColorPickerPopover', () => {
|
||||
const selectedTheme = getThemeKnob();
|
||||
|
||||
return (
|
||||
<SeriesColorPickerPopover
|
||||
color="#BC67E6"
|
||||
onChange={color => {
|
||||
console.log(color);
|
||||
}}
|
||||
theme={selectedTheme || undefined}
|
||||
/>
|
||||
);
|
||||
});
|
@ -0,0 +1,75 @@
|
||||
import React from 'react';
|
||||
import { mount, ReactWrapper } from 'enzyme';
|
||||
import { ColorPickerPopover } from './ColorPickerPopover';
|
||||
import { getColorDefinitionByName, getNamedColorPalette } from '../../utils/namedColorsPalette';
|
||||
import { ColorSwatch } from './NamedColorsGroup';
|
||||
import { flatten } from 'lodash';
|
||||
import { GrafanaTheme } from '../../types';
|
||||
|
||||
const allColors = flatten(Array.from(getNamedColorPalette().values()));
|
||||
|
||||
describe('ColorPickerPopover', () => {
|
||||
const BasicGreen = getColorDefinitionByName('green');
|
||||
const BasicBlue = getColorDefinitionByName('blue');
|
||||
|
||||
describe('rendering', () => {
|
||||
it('should render provided color as selected if color provided by name', () => {
|
||||
const wrapper = mount(<ColorPickerPopover color={BasicGreen.name} onChange={() => {}} />);
|
||||
const selectedSwatch = wrapper.find(ColorSwatch).findWhere(node => node.key() === BasicGreen.name);
|
||||
const notSelectedSwatches = wrapper.find(ColorSwatch).filterWhere(node => node.prop('isSelected') === false);
|
||||
|
||||
expect(selectedSwatch.length).toBe(1);
|
||||
expect(notSelectedSwatches.length).toBe(allColors.length - 1);
|
||||
expect(selectedSwatch.prop('isSelected')).toBe(true);
|
||||
});
|
||||
|
||||
it('should render provided color as selected if color provided by hex', () => {
|
||||
const wrapper = mount(<ColorPickerPopover color={BasicGreen.variants.dark} onChange={() => {}} />);
|
||||
const selectedSwatch = wrapper.find(ColorSwatch).findWhere(node => node.key() === BasicGreen.name);
|
||||
const notSelectedSwatches = wrapper.find(ColorSwatch).filterWhere(node => node.prop('isSelected') === false);
|
||||
|
||||
expect(selectedSwatch.length).toBe(1);
|
||||
expect(notSelectedSwatches.length).toBe(allColors.length - 1);
|
||||
expect(selectedSwatch.prop('isSelected')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('named colors support', () => {
|
||||
const onChangeSpy = jest.fn();
|
||||
let wrapper: ReactWrapper;
|
||||
|
||||
afterEach(() => {
|
||||
wrapper.unmount();
|
||||
onChangeSpy.mockClear();
|
||||
});
|
||||
|
||||
it('should pass hex color value to onChange prop by default', () => {
|
||||
wrapper = mount(
|
||||
<ColorPickerPopover color={BasicGreen.variants.dark} onChange={onChangeSpy} theme={GrafanaTheme.Light} />
|
||||
);
|
||||
const basicBlueSwatch = wrapper.find(ColorSwatch).findWhere(node => node.key() === BasicBlue.name);
|
||||
|
||||
basicBlueSwatch.simulate('click');
|
||||
|
||||
expect(onChangeSpy).toBeCalledTimes(1);
|
||||
expect(onChangeSpy).toBeCalledWith(BasicBlue.variants.light);
|
||||
});
|
||||
|
||||
it('should pass color name to onChange prop when named colors enabled', () => {
|
||||
wrapper = mount(
|
||||
<ColorPickerPopover
|
||||
enableNamedColors
|
||||
color={BasicGreen.variants.dark}
|
||||
onChange={onChangeSpy}
|
||||
theme={GrafanaTheme.Light}
|
||||
/>
|
||||
);
|
||||
const basicBlueSwatch = wrapper.find(ColorSwatch).findWhere(node => node.key() === BasicBlue.name);
|
||||
|
||||
basicBlueSwatch.simulate('click');
|
||||
|
||||
expect(onChangeSpy).toBeCalledTimes(1);
|
||||
expect(onChangeSpy).toBeCalledWith(BasicBlue.name);
|
||||
});
|
||||
});
|
||||
});
|
@ -1,112 +1,129 @@
|
||||
import React from 'react';
|
||||
import $ from 'jquery';
|
||||
import tinycolor from 'tinycolor2';
|
||||
import { ColorPalette } from './ColorPalette';
|
||||
import { SpectrumPicker } from './SpectrumPicker';
|
||||
import { NamedColorsPalette } from './NamedColorsPalette';
|
||||
import { getColorName, getColorFromHexRgbOrName } from '../../utils/namedColorsPalette';
|
||||
import { ColorPickerProps, warnAboutColorPickerPropsDeprecation } from './ColorPicker';
|
||||
import { GrafanaTheme } from '../../types';
|
||||
import { PopperContentProps } from '../Tooltip/PopperController';
|
||||
import SpectrumPalette from './SpectrumPalette';
|
||||
|
||||
const DEFAULT_COLOR = '#000000';
|
||||
|
||||
export interface Props {
|
||||
color: string;
|
||||
onColorSelect: (c: string) => void;
|
||||
export interface Props<T> extends ColorPickerProps, PopperContentProps {
|
||||
customPickers?: T;
|
||||
}
|
||||
|
||||
export class ColorPickerPopover extends React.Component<Props, any> {
|
||||
pickerNavElem: any;
|
||||
type PickerType = 'palette' | 'spectrum';
|
||||
|
||||
constructor(props: Props) {
|
||||
interface CustomPickersDescriptor {
|
||||
[key: string]: {
|
||||
tabComponent: React.ComponentType<ColorPickerProps>;
|
||||
name: string;
|
||||
};
|
||||
}
|
||||
interface State<T> {
|
||||
activePicker: PickerType | keyof T;
|
||||
}
|
||||
|
||||
export class ColorPickerPopover<T extends CustomPickersDescriptor> extends React.Component<Props<T>, State<T>> {
|
||||
constructor(props: Props<T>) {
|
||||
super(props);
|
||||
this.state = {
|
||||
tab: 'palette',
|
||||
color: this.props.color || DEFAULT_COLOR,
|
||||
colorString: this.props.color || DEFAULT_COLOR,
|
||||
activePicker: 'palette',
|
||||
};
|
||||
warnAboutColorPickerPropsDeprecation('ColorPickerPopover', props);
|
||||
}
|
||||
|
||||
setPickerNavElem(elem: any) {
|
||||
this.pickerNavElem = $(elem);
|
||||
}
|
||||
getTabClassName = (tabName: PickerType | keyof T) => {
|
||||
const { activePicker } = this.state;
|
||||
return `ColorPickerPopover__tab ${activePicker === tabName && 'ColorPickerPopover__tab--active'}`;
|
||||
};
|
||||
|
||||
setColor(color: string) {
|
||||
const newColor = tinycolor(color);
|
||||
if (newColor.isValid()) {
|
||||
this.setState({ color: newColor.toString(), colorString: newColor.toString() });
|
||||
this.props.onColorSelect(color);
|
||||
handleChange = (color: any) => {
|
||||
const { onColorChange, onChange, enableNamedColors, theme } = this.props;
|
||||
const changeHandler = onColorChange || onChange;
|
||||
|
||||
if (enableNamedColors) {
|
||||
return changeHandler(color);
|
||||
}
|
||||
}
|
||||
changeHandler(getColorFromHexRgbOrName(color, theme));
|
||||
};
|
||||
|
||||
sampleColorSelected(color: string) {
|
||||
this.setColor(color);
|
||||
}
|
||||
handleTabChange = (tab: PickerType | keyof T) => {
|
||||
return () => this.setState({ activePicker: tab });
|
||||
};
|
||||
|
||||
spectrumColorSelected(color: any) {
|
||||
const rgbColor = color.toRgbString();
|
||||
this.setColor(rgbColor);
|
||||
}
|
||||
renderPicker = () => {
|
||||
const { activePicker } = this.state;
|
||||
const { color, theme } = this.props;
|
||||
|
||||
onColorStringChange(e: any) {
|
||||
const colorString = e.target.value;
|
||||
this.setState({ colorString: colorString });
|
||||
|
||||
const newColor = tinycolor(colorString);
|
||||
if (newColor.isValid()) {
|
||||
// Update only color state
|
||||
const newColorString = newColor.toString();
|
||||
this.setState({ color: newColorString });
|
||||
this.props.onColorSelect(newColorString);
|
||||
switch (activePicker) {
|
||||
case 'spectrum':
|
||||
return <SpectrumPalette color={color} onChange={this.handleChange} theme={theme} />;
|
||||
case 'palette':
|
||||
return <NamedColorsPalette color={getColorName(color, theme)} onChange={this.handleChange} theme={theme} />;
|
||||
default:
|
||||
return this.renderCustomPicker(activePicker);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
onColorStringBlur(e: any) {
|
||||
const colorString = e.target.value;
|
||||
this.setColor(colorString);
|
||||
}
|
||||
renderCustomPicker = (tabKey: keyof T) => {
|
||||
const { customPickers, color, theme } = this.props;
|
||||
if (!customPickers) {
|
||||
return null;
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.pickerNavElem.find('li:first').addClass('active');
|
||||
this.pickerNavElem.on('show', (e: any) => {
|
||||
// use href attr (#name => name)
|
||||
const tab = e.target.hash.slice(1);
|
||||
this.setState({ tab: tab });
|
||||
return React.createElement(customPickers[tabKey].tabComponent, {
|
||||
color,
|
||||
theme,
|
||||
onChange: this.handleChange,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
const paletteTab = (
|
||||
<div id="palette">
|
||||
<ColorPalette color={this.state.color} onColorSelect={this.sampleColorSelected.bind(this)} />
|
||||
</div>
|
||||
);
|
||||
const spectrumTab = (
|
||||
<div id="spectrum">
|
||||
<SpectrumPicker color={this.state.color} onColorSelect={this.spectrumColorSelected.bind(this)} options={{}} />
|
||||
</div>
|
||||
);
|
||||
const currentTab = this.state.tab === 'palette' ? paletteTab : spectrumTab;
|
||||
renderCustomPickerTabs = () => {
|
||||
const { customPickers } = this.props;
|
||||
|
||||
if (!customPickers) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="gf-color-picker">
|
||||
<ul className="nav nav-tabs" id="colorpickernav" ref={this.setPickerNavElem.bind(this)}>
|
||||
<li className="gf-tabs-item-colorpicker">
|
||||
<a href="#palette" data-toggle="tab">
|
||||
Colors
|
||||
</a>
|
||||
</li>
|
||||
<li className="gf-tabs-item-colorpicker">
|
||||
<a href="#spectrum" data-toggle="tab">
|
||||
Custom
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
<div className="gf-color-picker__body">{currentTab}</div>
|
||||
<div>
|
||||
<input
|
||||
className="gf-form-input gf-form-input--small"
|
||||
value={this.state.colorString}
|
||||
onChange={this.onColorStringChange.bind(this)}
|
||||
onBlur={this.onColorStringBlur.bind(this)}
|
||||
/>
|
||||
<>
|
||||
{Object.keys(customPickers).map(key => {
|
||||
return (
|
||||
<div
|
||||
className={this.getTabClassName(key)}
|
||||
onClick={this.handleTabChange(key)}
|
||||
key={key}
|
||||
>
|
||||
{customPickers[key].name}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
render() {
|
||||
const { theme } = this.props;
|
||||
const colorPickerTheme = theme || GrafanaTheme.Dark;
|
||||
|
||||
return (
|
||||
<div className={`ColorPickerPopover ColorPickerPopover--${colorPickerTheme}`}>
|
||||
<div className="ColorPickerPopover__tabs">
|
||||
<div
|
||||
className={this.getTabClassName('palette')}
|
||||
onClick={this.handleTabChange('palette')}
|
||||
>
|
||||
Colors
|
||||
</div>
|
||||
<div
|
||||
className={this.getTabClassName('spectrum')}
|
||||
onClick={this.handleTabChange('spectrum')}
|
||||
>
|
||||
Custom
|
||||
</div>
|
||||
{this.renderCustomPickerTabs()}
|
||||
</div>
|
||||
|
||||
<div className="ColorPickerPopover__content">{this.renderPicker()}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -0,0 +1,110 @@
|
||||
import React, { FunctionComponent } from 'react';
|
||||
import { Themeable, GrafanaTheme } from '../../types';
|
||||
import { ColorDefinition, getColorForTheme } from '../../utils/namedColorsPalette';
|
||||
import { Color } from 'csstype';
|
||||
import { find, upperFirst } from 'lodash';
|
||||
|
||||
type ColorChangeHandler = (color: ColorDefinition) => void;
|
||||
|
||||
export enum ColorSwatchVariant {
|
||||
Small = 'small',
|
||||
Large = 'large',
|
||||
}
|
||||
|
||||
interface ColorSwatchProps extends Themeable, React.DOMAttributes<HTMLDivElement> {
|
||||
color: string;
|
||||
label?: string;
|
||||
variant?: ColorSwatchVariant;
|
||||
isSelected?: boolean;
|
||||
}
|
||||
|
||||
export const ColorSwatch: FunctionComponent<ColorSwatchProps> = ({
|
||||
color,
|
||||
label,
|
||||
variant = ColorSwatchVariant.Small,
|
||||
isSelected,
|
||||
theme,
|
||||
...otherProps
|
||||
}) => {
|
||||
const isSmall = variant === ColorSwatchVariant.Small;
|
||||
const swatchSize = isSmall ? '16px' : '32px';
|
||||
const selectedSwatchBorder = theme === GrafanaTheme.Light ? '#ffffff' : '#1A1B1F';
|
||||
const swatchStyles = {
|
||||
width: swatchSize,
|
||||
height: swatchSize,
|
||||
borderRadius: '50%',
|
||||
background: `${color}`,
|
||||
marginRight: isSmall ? '0px' : '8px',
|
||||
boxShadow: isSelected ? `inset 0 0 0 2px ${color}, inset 0 0 0 4px ${selectedSwatchBorder}` : 'none',
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
{...otherProps}
|
||||
>
|
||||
<div style={swatchStyles} />
|
||||
{variant === ColorSwatchVariant.Large && <span>{label}</span>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface NamedColorsGroupProps extends Themeable {
|
||||
colors: ColorDefinition[];
|
||||
selectedColor?: Color;
|
||||
onColorSelect: ColorChangeHandler;
|
||||
key?: string;
|
||||
}
|
||||
|
||||
const NamedColorsGroup: FunctionComponent<NamedColorsGroupProps> = ({
|
||||
colors,
|
||||
selectedColor,
|
||||
onColorSelect,
|
||||
theme,
|
||||
...otherProps
|
||||
}) => {
|
||||
const primaryColor = find(colors, color => !!color.isPrimary);
|
||||
|
||||
return (
|
||||
<div {...otherProps} style={{ display: 'flex', flexDirection: 'column' }}>
|
||||
{primaryColor && (
|
||||
<ColorSwatch
|
||||
key={primaryColor.name}
|
||||
isSelected={primaryColor.name === selectedColor}
|
||||
variant={ColorSwatchVariant.Large}
|
||||
color={getColorForTheme(primaryColor, theme)}
|
||||
label={upperFirst(primaryColor.hue)}
|
||||
onClick={() => onColorSelect(primaryColor)}
|
||||
theme={theme}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
marginTop: '8px',
|
||||
}}
|
||||
>
|
||||
{colors.map(
|
||||
color =>
|
||||
!color.isPrimary && (
|
||||
<div key={color.name} style={{ marginRight: '4px' }}>
|
||||
<ColorSwatch
|
||||
key={color.name}
|
||||
isSelected={color.name === selectedColor}
|
||||
color={getColorForTheme(color, theme)}
|
||||
onClick={() => onColorSelect(color)}
|
||||
theme={theme}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default NamedColorsGroup;
|
@ -0,0 +1,52 @@
|
||||
import React from 'react';
|
||||
import { storiesOf } from '@storybook/react';
|
||||
import { NamedColorsPalette } from './NamedColorsPalette';
|
||||
import { getColorName, getColorDefinitionByName } from '../../utils/namedColorsPalette';
|
||||
import { withKnobs, select } from '@storybook/addon-knobs';
|
||||
import { withCenteredStory } from '../../utils/storybook/withCenteredStory';
|
||||
import { UseState } from '../../utils/storybook/UseState';
|
||||
|
||||
const BasicGreen = getColorDefinitionByName('green');
|
||||
const BasicBlue = getColorDefinitionByName('blue');
|
||||
const LightBlue = getColorDefinitionByName('light-blue');
|
||||
|
||||
const NamedColorsPaletteStories = storiesOf('UI/ColorPicker/Palettes/NamedColorsPalette', module);
|
||||
|
||||
NamedColorsPaletteStories.addDecorator(withKnobs).addDecorator(withCenteredStory);
|
||||
|
||||
NamedColorsPaletteStories.add('Named colors swatch - support for named colors', () => {
|
||||
const selectedColor = select(
|
||||
'Selected color',
|
||||
{
|
||||
Green: 'green',
|
||||
Red: 'red',
|
||||
'Light blue': 'light-blue',
|
||||
},
|
||||
'red'
|
||||
);
|
||||
|
||||
return (
|
||||
<UseState initialState={selectedColor}>
|
||||
{(selectedColor, updateSelectedColor) => {
|
||||
return <NamedColorsPalette color={selectedColor} onChange={updateSelectedColor} />;
|
||||
}}
|
||||
</UseState>
|
||||
);
|
||||
}).add('Named colors swatch - support for hex values', () => {
|
||||
const selectedColor = select(
|
||||
'Selected color',
|
||||
{
|
||||
Green: BasicGreen.variants.dark,
|
||||
Red: BasicBlue.variants.dark,
|
||||
'Light blue': LightBlue.variants.dark,
|
||||
},
|
||||
'red'
|
||||
);
|
||||
return (
|
||||
<UseState initialState={selectedColor}>
|
||||
{(selectedColor, updateSelectedColor) => {
|
||||
return <NamedColorsPalette color={getColorName(selectedColor)} onChange={updateSelectedColor} />;
|
||||
}}
|
||||
</UseState>
|
||||
);
|
||||
});
|
@ -0,0 +1,36 @@
|
||||
import React from 'react';
|
||||
import { mount, ReactWrapper } from 'enzyme';
|
||||
import { NamedColorsPalette } from './NamedColorsPalette';
|
||||
import { ColorSwatch } from './NamedColorsGroup';
|
||||
import { getColorDefinitionByName } from '../../utils';
|
||||
import { GrafanaTheme } from '../../types';
|
||||
|
||||
describe('NamedColorsPalette', () => {
|
||||
|
||||
const BasicGreen = getColorDefinitionByName('green');
|
||||
|
||||
describe('theme support for named colors', () => {
|
||||
let wrapper: ReactWrapper, selectedSwatch;
|
||||
|
||||
afterEach(() => {
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('should render provided color variant specific for theme', () => {
|
||||
wrapper = mount(<NamedColorsPalette color={BasicGreen.name} theme={GrafanaTheme.Dark} onChange={() => {}} />);
|
||||
selectedSwatch = wrapper.find(ColorSwatch).findWhere(node => node.key() === BasicGreen.name);
|
||||
expect(selectedSwatch.prop('color')).toBe(BasicGreen.variants.dark);
|
||||
|
||||
wrapper.unmount();
|
||||
wrapper = mount(<NamedColorsPalette color={BasicGreen.name} theme={GrafanaTheme.Light} onChange={() => {}} />);
|
||||
selectedSwatch = wrapper.find(ColorSwatch).findWhere(node => node.key() === BasicGreen.name);
|
||||
expect(selectedSwatch.prop('color')).toBe(BasicGreen.variants.light);
|
||||
});
|
||||
|
||||
it('should render dar variant of provided color when theme not provided', () => {
|
||||
wrapper = mount(<NamedColorsPalette color={BasicGreen.name} onChange={() => {}} />);
|
||||
selectedSwatch = wrapper.find(ColorSwatch).findWhere(node => node.key() === BasicGreen.name);
|
||||
expect(selectedSwatch.prop('color')).toBe(BasicGreen.variants.dark);
|
||||
});
|
||||
});
|
||||
});
|
@ -0,0 +1,39 @@
|
||||
import React from 'react';
|
||||
import { Color, getNamedColorPalette } from '../../utils/namedColorsPalette';
|
||||
import { Themeable } from '../../types/index';
|
||||
import NamedColorsGroup from './NamedColorsGroup';
|
||||
|
||||
interface NamedColorsPaletteProps extends Themeable {
|
||||
color?: Color;
|
||||
onChange: (colorName: string) => void;
|
||||
}
|
||||
|
||||
export const NamedColorsPalette = ({ color, onChange, theme }: NamedColorsPaletteProps) => {
|
||||
const swatches: JSX.Element[] = [];
|
||||
getNamedColorPalette().forEach((colors, hue) => {
|
||||
swatches.push(
|
||||
<NamedColorsGroup
|
||||
key={hue}
|
||||
theme={theme}
|
||||
selectedColor={color}
|
||||
colors={colors}
|
||||
onColorSelect={color => {
|
||||
onChange(color.name);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(3, 1fr)',
|
||||
gridRowGap: '24px',
|
||||
gridColumnGap: '24px',
|
||||
}}
|
||||
>
|
||||
{swatches}
|
||||
</div>
|
||||
);
|
||||
};
|
@ -1,85 +0,0 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import Drop from 'tether-drop';
|
||||
import { SeriesColorPickerPopover } from './SeriesColorPickerPopover';
|
||||
|
||||
export interface SeriesColorPickerProps {
|
||||
color: string;
|
||||
yaxis?: number;
|
||||
optionalClass?: string;
|
||||
onColorChange: (newColor: string) => void;
|
||||
onToggleAxis?: () => void;
|
||||
}
|
||||
|
||||
export class SeriesColorPicker extends React.Component<SeriesColorPickerProps> {
|
||||
pickerElem: any;
|
||||
colorPickerDrop: any;
|
||||
|
||||
static defaultProps = {
|
||||
optionalClass: '',
|
||||
yaxis: undefined,
|
||||
onToggleAxis: () => {},
|
||||
};
|
||||
|
||||
constructor(props: SeriesColorPickerProps) {
|
||||
super(props);
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.destroyDrop();
|
||||
}
|
||||
|
||||
onClickToOpen = () => {
|
||||
if (this.colorPickerDrop) {
|
||||
this.destroyDrop();
|
||||
}
|
||||
|
||||
const { color, yaxis, onColorChange, onToggleAxis } = this.props;
|
||||
const dropContent = (
|
||||
<SeriesColorPickerPopover color={color} yaxis={yaxis} onColorChange={onColorChange} onToggleAxis={onToggleAxis} />
|
||||
);
|
||||
const dropContentElem = document.createElement('div');
|
||||
ReactDOM.render(dropContent, dropContentElem);
|
||||
|
||||
const drop = new Drop({
|
||||
target: this.pickerElem,
|
||||
content: dropContentElem,
|
||||
position: 'bottom center',
|
||||
classes: 'drop-popover',
|
||||
openOn: 'hover',
|
||||
hoverCloseDelay: 200,
|
||||
remove: true,
|
||||
tetherOptions: {
|
||||
constraints: [{ to: 'scrollParent', attachment: 'none both' }],
|
||||
attachment: 'bottom center',
|
||||
},
|
||||
});
|
||||
|
||||
drop.on('close', this.closeColorPicker.bind(this));
|
||||
|
||||
this.colorPickerDrop = drop;
|
||||
this.colorPickerDrop.open();
|
||||
};
|
||||
|
||||
closeColorPicker() {
|
||||
setTimeout(() => {
|
||||
this.destroyDrop();
|
||||
}, 100);
|
||||
}
|
||||
|
||||
destroyDrop() {
|
||||
if (this.colorPickerDrop && this.colorPickerDrop.tether) {
|
||||
this.colorPickerDrop.destroy();
|
||||
this.colorPickerDrop = null;
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const { optionalClass, children } = this.props;
|
||||
return (
|
||||
<div className={optionalClass} ref={e => (this.pickerElem = e)} onClick={this.onClickToOpen}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
@ -1,23 +1,44 @@
|
||||
import React from 'react';
|
||||
import { ColorPickerPopover } from './ColorPickerPopover';
|
||||
import React, { FunctionComponent } from 'react';
|
||||
|
||||
export interface SeriesColorPickerPopoverProps {
|
||||
color: string;
|
||||
import { ColorPickerPopover } from './ColorPickerPopover';
|
||||
import { ColorPickerProps } from './ColorPicker';
|
||||
import { PopperContentProps } from '../Tooltip/PopperController';
|
||||
import { Switch } from '../Switch/Switch';
|
||||
|
||||
export interface SeriesColorPickerPopoverProps extends ColorPickerProps, PopperContentProps {
|
||||
yaxis?: number;
|
||||
onColorChange: (color: string) => void;
|
||||
onToggleAxis?: () => void;
|
||||
}
|
||||
|
||||
export class SeriesColorPickerPopover extends React.PureComponent<SeriesColorPickerPopoverProps, any> {
|
||||
render() {
|
||||
return (
|
||||
<div className="graph-legend-popover">
|
||||
{this.props.yaxis && <AxisSelector yaxis={this.props.yaxis} onToggleAxis={this.props.onToggleAxis} />}
|
||||
<ColorPickerPopover color={this.props.color} onColorSelect={this.props.onColorChange} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
export const SeriesColorPickerPopover: FunctionComponent<SeriesColorPickerPopoverProps> = props => {
|
||||
const { yaxis, onToggleAxis, color, ...colorPickerProps } = props;
|
||||
|
||||
return (
|
||||
<ColorPickerPopover
|
||||
{...colorPickerProps}
|
||||
color={color || '#000000'}
|
||||
customPickers={{
|
||||
yaxis: {
|
||||
name: 'Y-Axis',
|
||||
tabComponent: () => (
|
||||
<Switch
|
||||
key="yaxisSwitch"
|
||||
label="Use right y-axis"
|
||||
className="ColorPicker__axisSwitch"
|
||||
labelClass="ColorPicker__axisSwitchLabel"
|
||||
checked={yaxis === 2}
|
||||
onChange={() => {
|
||||
if (onToggleAxis) {
|
||||
onToggleAxis();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
),
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
interface AxisSelectorProps {
|
||||
yaxis: number;
|
||||
|
@ -0,0 +1,23 @@
|
||||
import React from 'react';
|
||||
import { storiesOf } from '@storybook/react';
|
||||
import { withKnobs } from '@storybook/addon-knobs';
|
||||
|
||||
import SpectrumPalette from './SpectrumPalette';
|
||||
import { withCenteredStory } from '../../utils/storybook/withCenteredStory';
|
||||
import { UseState } from '../../utils/storybook/UseState';
|
||||
import { getThemeKnob } from '../../utils/storybook/themeKnob';
|
||||
|
||||
const SpectrumPaletteStories = storiesOf('UI/ColorPicker/Palettes/SpectrumPalette', module);
|
||||
|
||||
SpectrumPaletteStories.addDecorator(withCenteredStory).addDecorator(withKnobs);
|
||||
|
||||
SpectrumPaletteStories.add('Named colors swatch - support for named colors', () => {
|
||||
const selectedTheme = getThemeKnob();
|
||||
return (
|
||||
<UseState initialState="red">
|
||||
{(selectedColor, updateSelectedColor) => {
|
||||
return <SpectrumPalette theme={selectedTheme} color={selectedColor} onChange={updateSelectedColor} />;
|
||||
}}
|
||||
</UseState>
|
||||
);
|
||||
});
|
@ -0,0 +1,100 @@
|
||||
import React from 'react';
|
||||
import { CustomPicker, ColorResult } from 'react-color';
|
||||
|
||||
import { Saturation, Hue, Alpha } from 'react-color/lib/components/common';
|
||||
import { getColorFromHexRgbOrName } from '../../utils/namedColorsPalette';
|
||||
import tinycolor from 'tinycolor2';
|
||||
import ColorInput from './ColorInput';
|
||||
import { Themeable, GrafanaTheme } from '../../types';
|
||||
import SpectrumPalettePointer, { SpectrumPalettePointerProps } from './SpectrumPalettePointer';
|
||||
|
||||
export interface SpectrumPaletteProps extends Themeable {
|
||||
color: string;
|
||||
onChange: (color: string) => void;
|
||||
}
|
||||
|
||||
const renderPointer = (theme?: GrafanaTheme) => (props: SpectrumPalettePointerProps) => (
|
||||
<SpectrumPalettePointer {...props} theme={theme} />
|
||||
);
|
||||
|
||||
// @ts-ignore
|
||||
const SpectrumPicker = CustomPicker<Themeable>(({ rgb, hsl, onChange, theme }) => {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
width: '100%',
|
||||
flexDirection: 'column',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexGrow: 1,
|
||||
flexDirection: 'column',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
position: 'relative',
|
||||
height: '100px',
|
||||
width: '100%',
|
||||
}}
|
||||
>
|
||||
{/*
|
||||
// @ts-ignore */}
|
||||
<Saturation onChange={onChange} hsl={hsl} hsv={tinycolor(hsl).toHsv()} />
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '16px',
|
||||
marginTop: '16px',
|
||||
position: 'relative',
|
||||
background: 'white',
|
||||
}}
|
||||
>
|
||||
{/*
|
||||
// @ts-ignore */}
|
||||
<Alpha rgb={rgb} hsl={hsl} a={rgb.a} onChange={onChange} pointer={renderPointer(theme)} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
position: 'relative',
|
||||
width: '16px',
|
||||
height: '100px',
|
||||
marginLeft: '16px',
|
||||
}}
|
||||
>
|
||||
{/*
|
||||
// @ts-ignore */}
|
||||
<Hue onChange={onChange} hsl={hsl} direction="vertical" pointer={renderPointer(theme)} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
const SpectrumPalette: React.FunctionComponent<SpectrumPaletteProps> = ({ color, onChange, theme }) => {
|
||||
return (
|
||||
<div>
|
||||
<SpectrumPicker
|
||||
color={tinycolor(getColorFromHexRgbOrName(color)).toRgb()}
|
||||
onChange={(a: ColorResult) => {
|
||||
onChange(tinycolor(a.rgb).toString());
|
||||
}}
|
||||
theme={theme}
|
||||
/>
|
||||
<ColorInput color={color} onChange={onChange} style={{ marginTop: '16px' }} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SpectrumPalette;
|
@ -0,0 +1,80 @@
|
||||
import React from 'react';
|
||||
import { GrafanaTheme, Themeable } from '../../types';
|
||||
|
||||
export interface SpectrumPalettePointerProps extends Themeable {
|
||||
direction?: string;
|
||||
}
|
||||
|
||||
const SpectrumPalettePointer: React.FunctionComponent<SpectrumPalettePointerProps> = ({
|
||||
theme,
|
||||
direction,
|
||||
}) => {
|
||||
const styles = {
|
||||
picker: {
|
||||
width: '16px',
|
||||
height: '16px',
|
||||
transform: direction === 'vertical' ? 'translate(0, -8px)' : 'translate(-8px, 0)',
|
||||
},
|
||||
};
|
||||
|
||||
const pointerColor = theme === GrafanaTheme.Light ? '#3F444D' : '#8E8E8E';
|
||||
|
||||
let pointerStyles: React.CSSProperties = {
|
||||
position: 'absolute',
|
||||
left: '6px',
|
||||
width: '0',
|
||||
height: '0',
|
||||
borderStyle: 'solid',
|
||||
background: 'none',
|
||||
};
|
||||
|
||||
let topArrowStyles: React.CSSProperties = {
|
||||
top: '-7px',
|
||||
borderWidth: '6px 3px 0px 3px',
|
||||
borderColor: `${pointerColor} transparent transparent transparent`,
|
||||
};
|
||||
|
||||
let bottomArrowStyles: React.CSSProperties = {
|
||||
bottom: '-7px',
|
||||
borderWidth: '0px 3px 6px 3px',
|
||||
borderColor: ` transparent transparent ${pointerColor} transparent`,
|
||||
};
|
||||
|
||||
if (direction === 'vertical') {
|
||||
pointerStyles = {
|
||||
...pointerStyles,
|
||||
left: 'auto',
|
||||
};
|
||||
topArrowStyles = {
|
||||
borderWidth: '3px 0px 3px 6px',
|
||||
borderColor: `transparent transparent transparent ${pointerColor}`,
|
||||
left: '-7px',
|
||||
top: '7px',
|
||||
};
|
||||
bottomArrowStyles = {
|
||||
borderWidth: '3px 6px 3px 0px',
|
||||
borderColor: `transparent ${pointerColor} transparent transparent`,
|
||||
right: '-7px',
|
||||
top: '7px',
|
||||
};
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={styles.picker}>
|
||||
<div
|
||||
style={{
|
||||
...pointerStyles,
|
||||
...topArrowStyles,
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
...pointerStyles,
|
||||
...bottomArrowStyles,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SpectrumPalettePointer;
|
@ -1,72 +0,0 @@
|
||||
import React from 'react';
|
||||
import _ from 'lodash';
|
||||
import $ from 'jquery';
|
||||
import '../../vendor/spectrum';
|
||||
|
||||
export interface Props {
|
||||
color: string;
|
||||
options: object;
|
||||
onColorSelect: (c: string) => void;
|
||||
}
|
||||
|
||||
export class SpectrumPicker extends React.Component<Props, any> {
|
||||
elem: any;
|
||||
isMoving: boolean;
|
||||
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
this.onSpectrumMove = this.onSpectrumMove.bind(this);
|
||||
this.setComponentElem = this.setComponentElem.bind(this);
|
||||
}
|
||||
|
||||
setComponentElem(elem: any) {
|
||||
this.elem = $(elem);
|
||||
}
|
||||
|
||||
onSpectrumMove(color: any) {
|
||||
this.isMoving = true;
|
||||
this.props.onColorSelect(color);
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
const spectrumOptions = _.assignIn(
|
||||
{
|
||||
flat: true,
|
||||
showAlpha: true,
|
||||
showButtons: false,
|
||||
color: this.props.color,
|
||||
appendTo: this.elem,
|
||||
move: this.onSpectrumMove,
|
||||
},
|
||||
this.props.options
|
||||
);
|
||||
|
||||
this.elem.spectrum(spectrumOptions);
|
||||
this.elem.spectrum('show');
|
||||
this.elem.spectrum('set', this.props.color);
|
||||
}
|
||||
|
||||
componentWillUpdate(nextProps: any) {
|
||||
// If user move pointer over spectrum field this produce 'move' event and component
|
||||
// may update props.color. We don't want to update spectrum color in this case, so we can use
|
||||
// isMoving flag for tracking moving state. Flag should be cleared in componentDidUpdate() which
|
||||
// is called after updating occurs (when user finished moving).
|
||||
if (!this.isMoving) {
|
||||
this.elem.spectrum('set', nextProps.color);
|
||||
}
|
||||
}
|
||||
|
||||
componentDidUpdate() {
|
||||
if (this.isMoving) {
|
||||
this.isMoving = false;
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.elem.spectrum('destroy');
|
||||
}
|
||||
|
||||
render() {
|
||||
return <div className="spectrum-container" ref={this.setComponentElem} />;
|
||||
}
|
||||
}
|
@ -1,8 +1,172 @@
|
||||
$arrowSize: 15px;
|
||||
.ColorPicker {
|
||||
@extend .popper;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.ColorPicker__arrow {
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-style: solid;
|
||||
position: absolute;
|
||||
margin: 0px;
|
||||
|
||||
&[data-placement^='top'] {
|
||||
border-width: $arrowSize $arrowSize 0 $arrowSize;
|
||||
border-left-color: transparent;
|
||||
border-right-color: transparent;
|
||||
border-bottom-color: transparent;
|
||||
bottom: -$arrowSize;
|
||||
left: calc(50%-#{$arrowSize});
|
||||
padding-top: $arrowSize;
|
||||
}
|
||||
|
||||
&[data-placement^='bottom'] {
|
||||
border-width: 0 $arrowSize $arrowSize $arrowSize;
|
||||
border-left-color: transparent;
|
||||
border-right-color: transparent;
|
||||
border-top-color: transparent;
|
||||
top: 0;
|
||||
left: calc(50%-#{$arrowSize});
|
||||
}
|
||||
|
||||
&[data-placement^='bottom-start'] {
|
||||
border-width: 0 $arrowSize $arrowSize $arrowSize;
|
||||
border-left-color: transparent;
|
||||
border-right-color: transparent;
|
||||
border-top-color: transparent;
|
||||
top: 0;
|
||||
left: $arrowSize;
|
||||
}
|
||||
|
||||
&[data-placement^='bottom-end'] & {
|
||||
border-width: 0 $arrowSize $arrowSize $arrowSize;
|
||||
border-left-color: transparent;
|
||||
border-right-color: transparent;
|
||||
border-top-color: transparent;
|
||||
top: 0;
|
||||
left: calc(100% -$arrowSize);
|
||||
}
|
||||
|
||||
&[data-placement^='right'] {
|
||||
border-width: $arrowSize $arrowSize $arrowSize 0;
|
||||
border-left-color: transparent;
|
||||
border-top-color: transparent;
|
||||
border-bottom-color: transparent;
|
||||
left: 0;
|
||||
top: calc(50%-#{$arrowSize});
|
||||
}
|
||||
|
||||
&[data-placement^='left'] {
|
||||
border-width: $arrowSize 0 $arrowSize $arrowSize;
|
||||
border-top-color: transparent;
|
||||
border-right-color: transparent;
|
||||
border-bottom-color: transparent;
|
||||
right: -$arrowSize;
|
||||
top: calc(50%-#{$arrowSize});
|
||||
}
|
||||
}
|
||||
|
||||
.ColorPicker__arrow--light {
|
||||
border-color: #ffffff;
|
||||
}
|
||||
|
||||
.ColorPicker__arrow--dark {
|
||||
border-color: #1e2028;
|
||||
}
|
||||
|
||||
// Top
|
||||
.ColorPicker[data-placement^='top'] {
|
||||
padding-bottom: $arrowSize;
|
||||
}
|
||||
|
||||
// Bottom
|
||||
.ColorPicker[data-placement^='bottom'] {
|
||||
padding-top: $arrowSize;
|
||||
}
|
||||
|
||||
.ColorPicker[data-placement^='bottom-start'] {
|
||||
padding-top: $arrowSize;
|
||||
}
|
||||
|
||||
.ColorPicker[data-placement^='bottom-end'] {
|
||||
padding-top: $arrowSize;
|
||||
}
|
||||
|
||||
// Right
|
||||
.ColorPicker[data-placement^='right'] {
|
||||
padding-left: $arrowSize;
|
||||
}
|
||||
|
||||
// Left
|
||||
.ColorPicker[data-placement^='left'] {
|
||||
padding-right: $arrowSize;
|
||||
}
|
||||
|
||||
.ColorPickerPopover {
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.ColorPickerPopover--light {
|
||||
color: black;
|
||||
background: linear-gradient(180deg, #ffffff 0%, #f7f8fa 104.25%);
|
||||
box-shadow: 0px 2px 4px #dde4ed, 0px 0px 2px #dde4ed;
|
||||
}
|
||||
|
||||
.ColorPickerPopover--dark {
|
||||
color: #d8d9da;
|
||||
background: linear-gradient(180deg, #1e2028 0%, #161719 104.25%);
|
||||
box-shadow: 0px 2px 4px #000000, 0px 0px 2px #000000;
|
||||
|
||||
.ColorPickerPopover__tab {
|
||||
background: #303133;
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
}
|
||||
.ColorPickerPopover__tab--active {
|
||||
background: none;
|
||||
}
|
||||
}
|
||||
|
||||
.ColorPickerPopover__content {
|
||||
width: 336px;
|
||||
min-height: 184px;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.ColorPickerPopover__tabs {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
border-radius: 3px 3px 0 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.ColorPickerPopover__tab {
|
||||
width: 50%;
|
||||
text-align: center;
|
||||
padding: 8px 0;
|
||||
background: #dde4ed;
|
||||
}
|
||||
|
||||
.ColorPickerPopover__tab--active {
|
||||
background: white;
|
||||
}
|
||||
|
||||
.ColorPicker__axisSwitch {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.ColorPicker__axisSwitchLabel {
|
||||
display: flex;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.sp-replacer {
|
||||
background: inherit;
|
||||
border: none;
|
||||
color: inherit;
|
||||
padding: 0;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.sp-replacer:hover,
|
||||
@ -35,10 +199,22 @@
|
||||
margin: 0;
|
||||
float: left;
|
||||
z-index: 0;
|
||||
background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAwAAAAMCAIAAADZF8uwAAAAGUlEQVQYV2M4gwH+YwCGIasIUwhT25BVBADtzYNYrHvv4gAAAABJRU5ErkJggg==);
|
||||
}
|
||||
|
||||
.sp-preview-inner,
|
||||
.sp-alpha-inner,
|
||||
.sp-thumb-inner {
|
||||
display: block;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
}
|
||||
|
||||
.gf-color-picker__body {
|
||||
padding-bottom: 10px;
|
||||
padding-bottom: $arrowSize;
|
||||
padding-left: 6px;
|
||||
}
|
||||
|
||||
@ -47,3 +223,18 @@
|
||||
width: 210px;
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: Remove. This is a temporary solution until color picker popovers are used
|
||||
// with Drop.js.
|
||||
.drop-popover.drop-popover--transparent {
|
||||
.drop-content {
|
||||
border: none;
|
||||
background: none;
|
||||
padding: 0;
|
||||
max-width: none;
|
||||
|
||||
&:before {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -9,6 +9,8 @@ interface Props {
|
||||
autoHideDuration?: number;
|
||||
autoHeightMax?: string;
|
||||
hideTracksWhenNotNeeded?: boolean;
|
||||
renderTrackHorizontal?: React.FunctionComponent<any>;
|
||||
renderTrackVertical?: React.FunctionComponent<any>;
|
||||
scrollTop?: number;
|
||||
setScrollTop: (event: any) => void;
|
||||
autoHeightMin?: number | string;
|
||||
@ -66,6 +68,8 @@ export class CustomScrollbar extends PureComponent<Props> {
|
||||
autoHide,
|
||||
autoHideTimeout,
|
||||
hideTracksWhenNotNeeded,
|
||||
renderTrackHorizontal,
|
||||
renderTrackVertical,
|
||||
} = this.props;
|
||||
|
||||
return (
|
||||
@ -81,8 +85,8 @@ export class CustomScrollbar extends PureComponent<Props> {
|
||||
// Before these where set to inhert but that caused problems with cut of legends in firefox
|
||||
autoHeightMax={autoHeightMax}
|
||||
autoHeightMin={autoHeightMin}
|
||||
renderTrackHorizontal={props => <div {...props} className="track-horizontal" />}
|
||||
renderTrackVertical={props => <div {...props} className="track-vertical" />}
|
||||
renderTrackHorizontal={renderTrackHorizontal || (props => <div {...props} className="track-horizontal" />)}
|
||||
renderTrackVertical={renderTrackVertical || (props => <div {...props} className="track-vertical" />)}
|
||||
renderThumbHorizontal={props => <div {...props} className="thumb-horizontal" />}
|
||||
renderThumbVertical={props => <div {...props} className="thumb-vertical" />}
|
||||
renderView={props => <div {...props} className="view" />}
|
||||
|
@ -0,0 +1,24 @@
|
||||
import React, { FunctionComponent } from 'react';
|
||||
import { storiesOf } from '@storybook/react';
|
||||
import { DeleteButton } from '@grafana/ui';
|
||||
|
||||
const CenteredStory: FunctionComponent<{}> = ({ children }) => {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
height: '100vh ',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
storiesOf('UI/DeleteButton', module)
|
||||
.addDecorator(story => <CenteredStory>{story()}</CenteredStory>)
|
||||
.add('default', () => {
|
||||
return <DeleteButton onConfirm={() => {}} />;
|
||||
});
|
@ -1,6 +1,6 @@
|
||||
import React, { FunctionComponent, ReactNode } from 'react';
|
||||
import classNames from 'classnames';
|
||||
import { Tooltip } from '..';
|
||||
import { Tooltip } from '../Tooltip/Tooltip';
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
@ -31,9 +31,9 @@ export const FormLabel: FunctionComponent<Props> = ({
|
||||
<label className={classes} {...rest} htmlFor={htmlFor}>
|
||||
{children}
|
||||
{tooltip && (
|
||||
<Tooltip placement="auto" content={tooltip}>
|
||||
<div className="gf-form-help-icon--right-normal">
|
||||
<i className="gicon gicon-question gicon--has-hover" />
|
||||
<Tooltip placement="top" content={tooltip} theme={"info"}>
|
||||
<div className="gf-form-help-icon gf-form-help-icon--right-normal">
|
||||
<i className="fa fa-info-circle" />
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
@ -116,7 +116,7 @@ describe('Format value', () => {
|
||||
|
||||
const result = instance.formatValue(value);
|
||||
|
||||
expect(result).toEqual(' 6.0 ');
|
||||
expect(result).toEqual('6.0');
|
||||
});
|
||||
|
||||
it('should return formatted value if there are no matching value mappings', () => {
|
||||
@ -129,7 +129,7 @@ describe('Format value', () => {
|
||||
|
||||
const result = instance.formatValue(value);
|
||||
|
||||
expect(result).toEqual(' 10.0 ');
|
||||
expect(result).toEqual('10.0');
|
||||
});
|
||||
|
||||
it('should return mapped value if there are matching value mappings', () => {
|
||||
@ -142,6 +142,6 @@ describe('Format value', () => {
|
||||
|
||||
const result = instance.formatValue(value);
|
||||
|
||||
expect(result).toEqual(' 1-20 ');
|
||||
expect(result).toEqual('1-20');
|
||||
});
|
||||
});
|
||||
|
@ -1,10 +1,11 @@
|
||||
import React, { PureComponent } from 'react';
|
||||
import $ from 'jquery';
|
||||
|
||||
import { ValueMapping, Threshold, ThemeName, BasicGaugeColor, ThemeNames } from '../../types/panel';
|
||||
import { TimeSeriesVMs } from '../../types/series';
|
||||
import { getValueFormat } from '../../utils/valueFormats/valueFormats';
|
||||
import { TimeSeriesValue, getMappedValue } from '../../utils/valueMappings';
|
||||
import { ValueMapping, Threshold, BasicGaugeColor, TimeSeriesVMs, GrafanaTheme } from '../../types';
|
||||
import { getMappedValue } from '../../utils/valueMappings';
|
||||
import { getColorFromHexRgbOrName, getValueFormat } from '../../utils';
|
||||
|
||||
type TimeSeriesValue = string | number | null;
|
||||
|
||||
export interface Props {
|
||||
decimals: number;
|
||||
@ -21,9 +22,11 @@ export interface Props {
|
||||
suffix: string;
|
||||
unit: string;
|
||||
width: number;
|
||||
theme?: ThemeName;
|
||||
theme?: GrafanaTheme;
|
||||
}
|
||||
|
||||
const FONT_SCALE = 1;
|
||||
|
||||
export class Gauge extends PureComponent<Props> {
|
||||
canvasElement: any;
|
||||
|
||||
@ -38,7 +41,7 @@ export class Gauge extends PureComponent<Props> {
|
||||
thresholds: [],
|
||||
unit: 'none',
|
||||
stat: 'avg',
|
||||
theme: ThemeNames.Dark,
|
||||
theme: GrafanaTheme.Dark,
|
||||
};
|
||||
|
||||
componentDidMount() {
|
||||
@ -59,7 +62,7 @@ export class Gauge extends PureComponent<Props> {
|
||||
if (valueMappings.length > 0) {
|
||||
const valueMappedValue = getMappedValue(valueMappings, value);
|
||||
if (valueMappedValue) {
|
||||
return `${prefix} ${valueMappedValue.text} ${suffix}`;
|
||||
return `${prefix && prefix + ' '}${valueMappedValue.text}${suffix && ' ' + suffix}`;
|
||||
}
|
||||
}
|
||||
|
||||
@ -67,50 +70,55 @@ export class Gauge extends PureComponent<Props> {
|
||||
const formattedValue = formatFunc(value as number, decimals);
|
||||
const handleNoValueValue = formattedValue || 'no value';
|
||||
|
||||
return `${prefix} ${handleNoValueValue} ${suffix}`;
|
||||
return `${prefix && prefix + ' '}${handleNoValueValue}${suffix && ' ' + suffix}`;
|
||||
}
|
||||
|
||||
getFontColor(value: TimeSeriesValue) {
|
||||
const { thresholds } = this.props;
|
||||
const { thresholds, theme } = this.props;
|
||||
|
||||
if (thresholds.length === 1) {
|
||||
return thresholds[0].color;
|
||||
return getColorFromHexRgbOrName(thresholds[0].color, theme);
|
||||
}
|
||||
|
||||
const atThreshold = thresholds.filter(threshold => (value as number) === threshold.value)[0];
|
||||
if (atThreshold) {
|
||||
return atThreshold.color;
|
||||
return getColorFromHexRgbOrName(atThreshold.color, theme);
|
||||
}
|
||||
|
||||
const belowThreshold = thresholds.filter(threshold => (value as number) > threshold.value);
|
||||
|
||||
if (belowThreshold.length > 0) {
|
||||
const nearestThreshold = belowThreshold.sort((t1, t2) => t2.value - t1.value)[0];
|
||||
return nearestThreshold.color;
|
||||
return getColorFromHexRgbOrName(nearestThreshold.color, theme);
|
||||
}
|
||||
|
||||
return BasicGaugeColor.Red;
|
||||
}
|
||||
|
||||
getFormattedThresholds() {
|
||||
const { maxValue, minValue, thresholds } = this.props;
|
||||
const { maxValue, minValue, thresholds, theme } = this.props;
|
||||
|
||||
const thresholdsSortedByIndex = [...thresholds].sort((t1, t2) => t1.index - t2.index);
|
||||
const lastThreshold = thresholdsSortedByIndex[thresholdsSortedByIndex.length - 1];
|
||||
|
||||
const formattedThresholds = [
|
||||
return [
|
||||
...thresholdsSortedByIndex.map(threshold => {
|
||||
if (threshold.index === 0) {
|
||||
return { value: minValue, color: threshold.color };
|
||||
return { value: minValue, color: getColorFromHexRgbOrName(threshold.color, theme) };
|
||||
}
|
||||
|
||||
const previousThreshold = thresholdsSortedByIndex[threshold.index - 1];
|
||||
return { value: threshold.value, color: previousThreshold.color };
|
||||
return { value: threshold.value, color: getColorFromHexRgbOrName(previousThreshold.color, theme) };
|
||||
}),
|
||||
{ value: maxValue, color: lastThreshold.color },
|
||||
{ value: maxValue, color: getColorFromHexRgbOrName(lastThreshold.color, theme) },
|
||||
];
|
||||
}
|
||||
|
||||
return formattedThresholds;
|
||||
getFontScale(length: number): number {
|
||||
if (length > 12) {
|
||||
return FONT_SCALE - length * 5 / 120;
|
||||
}
|
||||
return FONT_SCALE - length * 5 / 105;
|
||||
}
|
||||
|
||||
draw() {
|
||||
@ -134,13 +142,14 @@ export class Gauge extends PureComponent<Props> {
|
||||
value = null;
|
||||
}
|
||||
|
||||
const formattedValue = this.formatValue(value) as string;
|
||||
const dimension = Math.min(width, height * 1.3);
|
||||
const backgroundColor = theme === ThemeNames.Light ? 'rgb(230,230,230)' : 'rgb(38,38,38)';
|
||||
const fontScale = parseInt('80', 10) / 100;
|
||||
const fontSize = Math.min(dimension / 5, 100) * fontScale;
|
||||
const backgroundColor = theme === GrafanaTheme.Light ? 'rgb(230,230,230)' : 'rgb(38,38,38)';
|
||||
const gaugeWidthReduceRatio = showThresholdLabels ? 1.5 : 1;
|
||||
const gaugeWidth = Math.min(dimension / 6, 60) / gaugeWidthReduceRatio;
|
||||
const thresholdMarkersWidth = gaugeWidth / 5;
|
||||
const fontSize =
|
||||
Math.min(dimension / 5, 100) * (formattedValue !== null ? this.getFontScale(formattedValue.length) : 1);
|
||||
const thresholdLabelFontSize = fontSize / 2.5;
|
||||
|
||||
const options = {
|
||||
@ -171,7 +180,7 @@ export class Gauge extends PureComponent<Props> {
|
||||
value: {
|
||||
color: this.getFontColor(value),
|
||||
formatter: () => {
|
||||
return this.formatValue(value);
|
||||
return formattedValue;
|
||||
},
|
||||
font: { size: fontSize, family: '"Helvetica Neue", Helvetica, Arial, sans-serif' },
|
||||
},
|
||||
|
@ -3,6 +3,7 @@ import renderer from 'react-test-renderer';
|
||||
import SelectOption from './SelectOption';
|
||||
import { OptionProps } from 'react-select/lib/components/Option';
|
||||
|
||||
// @ts-ignore
|
||||
const model: OptionProps<any> = {
|
||||
data: jest.fn(),
|
||||
cx: jest.fn(),
|
||||
|
@ -4,10 +4,11 @@ import _ from 'lodash';
|
||||
export interface Props {
|
||||
label: string;
|
||||
checked: boolean;
|
||||
className?: string;
|
||||
labelClass?: string;
|
||||
switchClass?: string;
|
||||
transparent?: boolean;
|
||||
onChange: (event) => any;
|
||||
onChange: (event?: React.SyntheticEvent<HTMLInputElement>) => void;
|
||||
}
|
||||
|
||||
export interface State {
|
||||
@ -19,20 +20,21 @@ export class Switch extends PureComponent<Props, State> {
|
||||
id: _.uniqueId(),
|
||||
};
|
||||
|
||||
internalOnChange = event => {
|
||||
internalOnChange = (event: React.FormEvent<HTMLInputElement>) => {
|
||||
event.stopPropagation();
|
||||
|
||||
this.props.onChange(event);
|
||||
};
|
||||
|
||||
render() {
|
||||
const { labelClass = '', switchClass = '', label, checked, transparent } = this.props;
|
||||
const { labelClass = '', switchClass = '', label, checked, transparent, className } = this.props;
|
||||
|
||||
const labelId = `check-${this.state.id}`;
|
||||
const labelClassName = `gf-form-label ${labelClass} ${transparent ? 'gf-form-label--transparent' : ''} pointer`;
|
||||
const switchClassName = `gf-form-switch ${switchClass} ${transparent ? 'gf-form-switch--transparent' : ''}`;
|
||||
|
||||
return (
|
||||
<label htmlFor={labelId} className="gf-form gf-form-switch-container">
|
||||
<label htmlFor={labelId} className={`gf-form gf-form-switch-container ${className}`}>
|
||||
{label && <div className={labelClassName}>{label}</div>}
|
||||
<div className={switchClassName}>
|
||||
<input id={labelId} type="checkbox" checked={checked} onChange={this.internalOnChange} />
|
@ -1,12 +1,11 @@
|
||||
import React, { PureComponent } from 'react';
|
||||
// import tinycolor, { ColorInput } from 'tinycolor2';
|
||||
|
||||
import { Threshold } from '../../types';
|
||||
import { Threshold, Themeable } from '../../types';
|
||||
import { ColorPicker } from '../ColorPicker/ColorPicker';
|
||||
import { PanelOptionsGroup } from '../PanelOptionsGroup/PanelOptionsGroup';
|
||||
import { colors } from '../../utils';
|
||||
import { getColorFromHexRgbOrName } from '@grafana/ui';
|
||||
|
||||
export interface Props {
|
||||
export interface Props extends Themeable {
|
||||
thresholds: Threshold[];
|
||||
onChange: (thresholds: Threshold[]) => void;
|
||||
}
|
||||
@ -189,6 +188,7 @@ export class ThresholdsEditor extends PureComponent<Props, State> {
|
||||
|
||||
render() {
|
||||
const { thresholds } = this.state;
|
||||
const { theme } = this.props;
|
||||
|
||||
return (
|
||||
<PanelOptionsGroup title="Thresholds">
|
||||
@ -199,7 +199,10 @@ export class ThresholdsEditor extends PureComponent<Props, State> {
|
||||
<div className="thresholds-row-add-button" onClick={() => this.onAddThreshold(threshold.index + 1)}>
|
||||
<i className="fa fa-plus" />
|
||||
</div>
|
||||
<div className="thresholds-row-color-indicator" style={{ backgroundColor: threshold.color }} />
|
||||
<div
|
||||
className="thresholds-row-color-indicator"
|
||||
style={{ backgroundColor: getColorFromHexRgbOrName(threshold.color, theme) }}
|
||||
/>
|
||||
<div className="thresholds-row-input">{this.renderInput(threshold)}</div>
|
||||
</div>
|
||||
);
|
||||
|
@ -1,73 +1,88 @@
|
||||
import React, { PureComponent } from 'react';
|
||||
import * as PopperJS from 'popper.js';
|
||||
import { Manager, Popper as ReactPopper } from 'react-popper';
|
||||
import { Manager, Popper as ReactPopper, PopperArrowProps } from 'react-popper';
|
||||
import { Portal } from '@grafana/ui';
|
||||
import Transition from 'react-transition-group/Transition';
|
||||
|
||||
export enum Themes {
|
||||
Default = 'popper__background--default',
|
||||
Error = 'popper__background--error',
|
||||
Brand = 'popper__background--brand',
|
||||
}
|
||||
import { PopperContent } from './PopperController';
|
||||
|
||||
const defaultTransitionStyles = {
|
||||
transition: 'opacity 200ms linear',
|
||||
opacity: 0,
|
||||
};
|
||||
|
||||
const transitionStyles: {[key: string]: object} = {
|
||||
const transitionStyles: { [key: string]: object } = {
|
||||
exited: { opacity: 0 },
|
||||
entering: { opacity: 0 },
|
||||
entered: { opacity: 1 },
|
||||
exiting: { opacity: 0 },
|
||||
entered: { opacity: 1, transitionDelay: '0s' },
|
||||
exiting: { opacity: 0, transitionDelay: '500ms' },
|
||||
};
|
||||
|
||||
interface Props extends React.DOMAttributes<HTMLDivElement> {
|
||||
renderContent: (content: any) => any;
|
||||
export type RenderPopperArrowFn = (
|
||||
props: {
|
||||
arrowProps: PopperArrowProps;
|
||||
placement: string;
|
||||
}
|
||||
) => JSX.Element;
|
||||
|
||||
interface Props extends React.HTMLAttributes<HTMLDivElement> {
|
||||
show: boolean;
|
||||
placement?: PopperJS.Placement;
|
||||
content: string | ((props: any) => JSX.Element);
|
||||
content: PopperContent<any>;
|
||||
referenceElement: PopperJS.ReferenceObject;
|
||||
theme?: Themes;
|
||||
wrapperClassName?: string;
|
||||
renderArrow?: RenderPopperArrowFn;
|
||||
}
|
||||
|
||||
class Popper extends PureComponent<Props> {
|
||||
render() {
|
||||
const { renderContent, show, placement, onMouseEnter, onMouseLeave, theme } = this.props;
|
||||
const { show, placement, onMouseEnter, onMouseLeave, className, wrapperClassName, renderArrow } = this.props;
|
||||
const { content } = this.props;
|
||||
|
||||
const popperBackgroundClassName = 'popper__background' + (theme ? ' ' + theme : '');
|
||||
|
||||
return (
|
||||
<Manager>
|
||||
<Transition in={show} timeout={100} mountOnEnter={true} unmountOnExit={true}>
|
||||
{transitionState => (
|
||||
<Portal>
|
||||
<ReactPopper placement={placement} referenceElement={this.props.referenceElement}>
|
||||
{({ ref, style, placement, arrowProps }) => {
|
||||
return (
|
||||
<div
|
||||
onMouseEnter={onMouseEnter}
|
||||
onMouseLeave={onMouseLeave}
|
||||
ref={ref}
|
||||
style={{
|
||||
...style,
|
||||
...defaultTransitionStyles,
|
||||
...transitionStyles[transitionState],
|
||||
}}
|
||||
data-placement={placement}
|
||||
className="popper"
|
||||
>
|
||||
<div className={popperBackgroundClassName}>
|
||||
{renderContent(content)}
|
||||
<div ref={arrowProps.ref} data-placement={placement} className="popper__arrow" />
|
||||
{transitionState => {
|
||||
return (
|
||||
<Portal>
|
||||
<ReactPopper
|
||||
placement={placement}
|
||||
referenceElement={this.props.referenceElement}
|
||||
// TODO: move modifiers config to popper controller
|
||||
modifiers={{ preventOverflow: { enabled: true, boundariesElement: 'window' } }}
|
||||
>
|
||||
{({ ref, style, placement, arrowProps, scheduleUpdate }) => {
|
||||
return (
|
||||
<div
|
||||
onMouseEnter={onMouseEnter}
|
||||
onMouseLeave={onMouseLeave}
|
||||
ref={ref}
|
||||
style={{
|
||||
...style,
|
||||
...defaultTransitionStyles,
|
||||
...transitionStyles[transitionState],
|
||||
}}
|
||||
data-placement={placement}
|
||||
className={`${wrapperClassName}`}
|
||||
>
|
||||
<div className={className}>
|
||||
{typeof content === 'string'
|
||||
? content
|
||||
: React.cloneElement(content, {
|
||||
updatePopperPosition: scheduleUpdate,
|
||||
})}
|
||||
{renderArrow &&
|
||||
renderArrow({
|
||||
arrowProps,
|
||||
placement,
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
</ReactPopper>
|
||||
</Portal>
|
||||
)}
|
||||
);
|
||||
}}
|
||||
</ReactPopper>
|
||||
</Portal>
|
||||
);
|
||||
}}
|
||||
</Transition>
|
||||
</Manager>
|
||||
);
|
||||
|
@ -1,16 +1,19 @@
|
||||
import React from 'react';
|
||||
import * as PopperJS from 'popper.js';
|
||||
import { Themes } from './Popper';
|
||||
|
||||
type PopperContent = string | (() => JSX.Element);
|
||||
// This API allows popovers to update Popper's position when e.g. popover content changes
|
||||
// updatePopperPosition is delivered to content by react-popper
|
||||
export interface PopperContentProps {
|
||||
updatePopperPosition?: () => void;
|
||||
}
|
||||
|
||||
export type PopperContent<T extends PopperContentProps> = string | React.ReactElement<T>;
|
||||
|
||||
export interface UsingPopperProps {
|
||||
show?: boolean;
|
||||
placement?: PopperJS.Placement;
|
||||
content: PopperContent;
|
||||
content: PopperContent<any>;
|
||||
children: JSX.Element;
|
||||
renderContent?: (content: PopperContent) => JSX.Element;
|
||||
theme?: Themes;
|
||||
}
|
||||
|
||||
type PopperControllerRenderProp = (
|
||||
@ -19,18 +22,16 @@ type PopperControllerRenderProp = (
|
||||
popperProps: {
|
||||
show: boolean;
|
||||
placement: PopperJS.Placement;
|
||||
content: string | ((props: any) => JSX.Element);
|
||||
renderContent: (content: any) => any;
|
||||
theme?: Themes;
|
||||
content: PopperContent<any>;
|
||||
}
|
||||
) => JSX.Element;
|
||||
|
||||
interface Props {
|
||||
placement?: PopperJS.Placement;
|
||||
content: PopperContent;
|
||||
content: PopperContent<any>;
|
||||
className?: string;
|
||||
children: PopperControllerRenderProp;
|
||||
theme?: Themes;
|
||||
hideAfter?: number;
|
||||
}
|
||||
|
||||
interface State {
|
||||
@ -39,6 +40,8 @@ interface State {
|
||||
}
|
||||
|
||||
class PopperController extends React.Component<Props, State> {
|
||||
private hideTimeout: any;
|
||||
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
|
||||
@ -60,6 +63,10 @@ class PopperController extends React.Component<Props, State> {
|
||||
}
|
||||
|
||||
showPopper = () => {
|
||||
if (this.hideTimeout) {
|
||||
clearTimeout(this.hideTimeout);
|
||||
}
|
||||
|
||||
this.setState(prevState => ({
|
||||
...prevState,
|
||||
show: true,
|
||||
@ -67,31 +74,29 @@ class PopperController extends React.Component<Props, State> {
|
||||
};
|
||||
|
||||
hidePopper = () => {
|
||||
if (this.props.hideAfter !== 0) {
|
||||
this.hideTimeout = setTimeout(() => {
|
||||
this.setState(prevState => ({
|
||||
...prevState,
|
||||
show: false,
|
||||
}));
|
||||
}, this.props.hideAfter);
|
||||
return;
|
||||
}
|
||||
this.setState(prevState => ({
|
||||
...prevState,
|
||||
show: false,
|
||||
}));
|
||||
};
|
||||
|
||||
renderContent(content: PopperContent) {
|
||||
if (typeof content === 'function') {
|
||||
// If it's a function we assume it's a React component
|
||||
const ReactComponent = content;
|
||||
return <ReactComponent />;
|
||||
}
|
||||
return content;
|
||||
}
|
||||
|
||||
render() {
|
||||
const { children, content, theme } = this.props;
|
||||
const { children, content } = this.props;
|
||||
const { show, placement } = this.state;
|
||||
|
||||
return children(this.showPopper, this.hidePopper, {
|
||||
show,
|
||||
placement,
|
||||
content,
|
||||
renderContent: this.renderContent,
|
||||
theme,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@ -1,10 +1,14 @@
|
||||
import React, { createRef } from 'react';
|
||||
import React, { createRef } from 'react';
|
||||
import * as PopperJS from 'popper.js';
|
||||
import Popper from './Popper';
|
||||
import PopperController, { UsingPopperProps } from './PopperController';
|
||||
|
||||
export const Tooltip = ({ children, renderContent, ...controllerProps }: UsingPopperProps) => {
|
||||
interface TooltipProps extends UsingPopperProps {
|
||||
theme?: 'info' | 'error';
|
||||
}
|
||||
export const Tooltip = ({ children, theme, ...controllerProps }: TooltipProps) => {
|
||||
const tooltipTriggerRef = createRef<PopperJS.ReferenceObject>();
|
||||
const popperBackgroundClassName = 'popper__background' + (theme ? ' popper__background--' + theme : '');
|
||||
|
||||
return (
|
||||
<PopperController {...controllerProps}>
|
||||
@ -17,6 +21,11 @@ export const Tooltip = ({ children, renderContent, ...controllerProps }: UsingPo
|
||||
onMouseEnter={showPopper}
|
||||
onMouseLeave={hidePopper}
|
||||
referenceElement={tooltipTriggerRef.current}
|
||||
wrapperClassName='popper'
|
||||
className={popperBackgroundClassName}
|
||||
renderArrow={({ arrowProps, placement }) => (
|
||||
<div className="popper__arrow" data-placement={placement} {...arrowProps} />
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{React.cloneElement(children, {
|
||||
|
@ -1,10 +1,11 @@
|
||||
$popper-margin-from-ref: 5px;
|
||||
|
||||
|
||||
@mixin popper-theme($backgroundColor, $arrowColor) {
|
||||
@mixin popper-theme($backgroundColor, $textColor) {
|
||||
background: $backgroundColor;
|
||||
color: $textColor;
|
||||
|
||||
.popper__arrow {
|
||||
border-color: $arrowColor;
|
||||
border-color: $backgroundColor;
|
||||
}
|
||||
}
|
||||
|
||||
@ -18,18 +19,23 @@ $popper-margin-from-ref: 5px;
|
||||
|
||||
.popper__background {
|
||||
background: $tooltipBackground;
|
||||
border-radius: $border-radius;
|
||||
border-radius: $border-radius-sm;
|
||||
box-shadow: 0 0 2px rgba(0, 0, 0, 0.5);
|
||||
padding: 10px;
|
||||
padding: 6px 10px;
|
||||
color: $tooltipColor;
|
||||
font-weight: 500;
|
||||
|
||||
.popper__arrow {
|
||||
border-color: $tooltipBackground;
|
||||
}
|
||||
|
||||
// Themes
|
||||
&.popper__background--error {
|
||||
@include popper-theme($tooltipBackgroundError, $tooltipBackgroundError);
|
||||
}
|
||||
|
||||
&.popper__background--brand {
|
||||
@include popper-theme($tooltipBackgroundBrand, $tooltipBackgroundBrand);
|
||||
@include gradient-vertical($red, $orange);
|
||||
&.popper__background--info {
|
||||
@include popper-theme($popover-help-bg, $popover-help-color);
|
||||
}
|
||||
}
|
||||
|
||||
@ -41,10 +47,6 @@ $popper-margin-from-ref: 5px;
|
||||
margin: 0px;
|
||||
}
|
||||
|
||||
.popper__arrow {
|
||||
border-color: $tooltipBackground;
|
||||
}
|
||||
|
||||
// Top
|
||||
.popper[data-placement^='top'] {
|
||||
padding-bottom: $popper-margin-from-ref;
|
||||
|
@ -14,12 +14,12 @@ export { FormLabel } from './FormLabel/FormLabel';
|
||||
export { FormField } from './FormField/FormField';
|
||||
|
||||
export { LoadingPlaceholder } from './LoadingPlaceholder/LoadingPlaceholder';
|
||||
export { ColorPicker } from './ColorPicker/ColorPicker';
|
||||
export { ColorPicker, SeriesColorPicker } from './ColorPicker/ColorPicker';
|
||||
export { SeriesColorPickerPopover } from './ColorPicker/SeriesColorPickerPopover';
|
||||
export { SeriesColorPicker } from './ColorPicker/SeriesColorPicker';
|
||||
export { ThresholdsEditor } from './ThresholdsEditor/ThresholdsEditor';
|
||||
export { Graph } from './Graph/Graph';
|
||||
export { PanelOptionsGroup } from './PanelOptionsGroup/PanelOptionsGroup';
|
||||
export { PanelOptionsGrid } from './PanelOptionsGrid/PanelOptionsGrid';
|
||||
export { ValueMappingsEditor } from './ValueMappingsEditor/ValueMappingsEditor';
|
||||
export { Gauge } from './Gauge/Gauge';
|
||||
export { Switch } from './Switch/Switch';
|
||||
|
@ -1,3 +1 @@
|
||||
@import 'vendor/spectrum';
|
||||
@import 'components/index';
|
||||
|
||||
|
@ -3,3 +3,12 @@ export * from './time';
|
||||
export * from './panel';
|
||||
export * from './plugin';
|
||||
export * from './datasource';
|
||||
|
||||
export enum GrafanaTheme {
|
||||
Light = 'light',
|
||||
Dark = 'dark',
|
||||
}
|
||||
|
||||
export interface Themeable {
|
||||
theme?: GrafanaTheme;
|
||||
}
|
||||
|
@ -36,7 +36,7 @@ export interface PanelMenuItem {
|
||||
export interface Threshold {
|
||||
index: number;
|
||||
value: number;
|
||||
color?: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
export enum BasicGaugeColor {
|
||||
@ -66,10 +66,3 @@ export interface RangeMap extends BaseMap {
|
||||
from: string;
|
||||
to: string;
|
||||
}
|
||||
|
||||
export type ThemeName = 'dark' | 'light';
|
||||
|
||||
export enum ThemeNames {
|
||||
Dark = 'dark',
|
||||
Light = 'light',
|
||||
}
|
||||
|
@ -9,7 +9,6 @@ export const ALERTING_COLOR = 'rgba(237, 46, 24, 1)';
|
||||
export const NO_DATA_COLOR = 'rgba(150, 150, 150, 1)';
|
||||
export const PENDING_COLOR = 'rgba(247, 149, 32, 1)';
|
||||
export const REGION_FILL_ALPHA = 0.09;
|
||||
|
||||
export const colors = [
|
||||
'#7EB26D', // 0: pale green
|
||||
'#EAB839', // 1: mustard
|
||||
|
@ -1,3 +1,4 @@
|
||||
export * from './processTimeSeries';
|
||||
export * from './valueFormats/valueFormats';
|
||||
export * from './colors';
|
||||
export * from './namedColorsPalette';
|
||||
|
66
packages/grafana-ui/src/utils/namedColorsPalette.test.ts
Normal file
66
packages/grafana-ui/src/utils/namedColorsPalette.test.ts
Normal file
@ -0,0 +1,66 @@
|
||||
import {
|
||||
getColorName,
|
||||
getColorDefinition,
|
||||
getColorByName,
|
||||
getColorFromHexRgbOrName,
|
||||
getColorDefinitionByName,
|
||||
} from './namedColorsPalette';
|
||||
import { GrafanaTheme } from '../types/index';
|
||||
|
||||
describe('colors', () => {
|
||||
const SemiDarkBlue = getColorDefinitionByName('semi-dark-blue');
|
||||
|
||||
describe('getColorDefinition', () => {
|
||||
it('returns undefined for unknown hex', () => {
|
||||
expect(getColorDefinition('#ff0000', GrafanaTheme.Light)).toBeUndefined();
|
||||
expect(getColorDefinition('#ff0000', GrafanaTheme.Dark)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns definition for known hex', () => {
|
||||
expect(getColorDefinition(SemiDarkBlue.variants.light, GrafanaTheme.Light)).toEqual(SemiDarkBlue);
|
||||
expect(getColorDefinition(SemiDarkBlue.variants.dark, GrafanaTheme.Dark)).toEqual(SemiDarkBlue);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getColorName', () => {
|
||||
it('returns undefined for unknown hex', () => {
|
||||
expect(getColorName('#ff0000')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns name for known hex', () => {
|
||||
expect(getColorName(SemiDarkBlue.variants.light, GrafanaTheme.Light)).toEqual(SemiDarkBlue.name);
|
||||
expect(getColorName(SemiDarkBlue.variants.dark, GrafanaTheme.Dark)).toEqual(SemiDarkBlue.name);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getColorByName', () => {
|
||||
it('returns undefined for unknown color', () => {
|
||||
expect(getColorByName('aruba-sunshine')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns color definiton for known color', () => {
|
||||
expect(getColorByName(SemiDarkBlue.name)).toBe(SemiDarkBlue);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getColorFromHexRgbOrName', () => {
|
||||
it('returns undefined for unknown color', () => {
|
||||
expect(() => getColorFromHexRgbOrName('aruba-sunshine')).toThrow();
|
||||
});
|
||||
|
||||
it('returns dark hex variant for known color if theme not specified', () => {
|
||||
expect(getColorFromHexRgbOrName(SemiDarkBlue.name)).toBe(SemiDarkBlue.variants.dark);
|
||||
});
|
||||
|
||||
it("returns correct variant's hex for known color if theme specified", () => {
|
||||
expect(getColorFromHexRgbOrName(SemiDarkBlue.name, GrafanaTheme.Light)).toBe(SemiDarkBlue.variants.light);
|
||||
});
|
||||
|
||||
it('returns color if specified as hex or rgb/a', () => {
|
||||
expect(getColorFromHexRgbOrName('ff0000')).toBe('ff0000');
|
||||
expect(getColorFromHexRgbOrName('#ff0000')).toBe('#ff0000');
|
||||
expect(getColorFromHexRgbOrName('rgb(0,0,0)')).toBe('rgb(0,0,0)');
|
||||
expect(getColorFromHexRgbOrName('rgba(0,0,0,1)')).toBe('rgba(0,0,0,1)');
|
||||
});
|
||||
});
|
||||
});
|
182
packages/grafana-ui/src/utils/namedColorsPalette.ts
Normal file
182
packages/grafana-ui/src/utils/namedColorsPalette.ts
Normal file
@ -0,0 +1,182 @@
|
||||
import { flatten } from 'lodash';
|
||||
import { GrafanaTheme } from '../types';
|
||||
|
||||
type Hue = 'green' | 'yellow' | 'red' | 'blue' | 'orange' | 'purple';
|
||||
|
||||
export type Color =
|
||||
| 'green'
|
||||
| 'dark-green'
|
||||
| 'semi-dark-green'
|
||||
| 'light-green'
|
||||
| 'super-light-green'
|
||||
| 'yellow'
|
||||
| 'dark-yellow'
|
||||
| 'semi-dark-yellow'
|
||||
| 'light-yellow'
|
||||
| 'super-light-yellow'
|
||||
| 'red'
|
||||
| 'dark-red'
|
||||
| 'semi-dark-red'
|
||||
| 'light-red'
|
||||
| 'super-light-red'
|
||||
| 'blue'
|
||||
| 'dark-blue'
|
||||
| 'semi-dark-blue'
|
||||
| 'light-blue'
|
||||
| 'super-light-blue'
|
||||
| 'orange'
|
||||
| 'dark-orange'
|
||||
| 'semi-dark-orange'
|
||||
| 'light-orange'
|
||||
| 'super-light-orange'
|
||||
| 'purple'
|
||||
| 'dark-purple'
|
||||
| 'semi-dark-purple'
|
||||
| 'light-purple'
|
||||
| 'super-light-purple';
|
||||
|
||||
type ThemeVariants = {
|
||||
dark: string;
|
||||
light: string;
|
||||
};
|
||||
|
||||
export type ColorDefinition = {
|
||||
hue: Hue;
|
||||
isPrimary?: boolean;
|
||||
name: Color;
|
||||
variants: ThemeVariants;
|
||||
};
|
||||
|
||||
let colorsPaletteInstance: Map<Hue, ColorDefinition[]>;
|
||||
|
||||
const buildColorDefinition = (
|
||||
hue: Hue,
|
||||
name: Color,
|
||||
[light, dark]: string[],
|
||||
isPrimary?: boolean
|
||||
): ColorDefinition => ({
|
||||
hue,
|
||||
name,
|
||||
variants: {
|
||||
light,
|
||||
dark,
|
||||
},
|
||||
isPrimary: !!isPrimary,
|
||||
});
|
||||
|
||||
export const getColorDefinitionByName = (name: Color): ColorDefinition => {
|
||||
return flatten(Array.from(getNamedColorPalette().values())).filter(definition => definition.name === name)[0];
|
||||
};
|
||||
|
||||
export const getColorDefinition = (hex: string, theme: GrafanaTheme): ColorDefinition | undefined => {
|
||||
return flatten(Array.from(getNamedColorPalette().values())).filter(definition => definition.variants[theme] === hex)[0];
|
||||
};
|
||||
|
||||
const isHex = (color: string) => {
|
||||
const hexRegex = /^((0x){0,1}|#{0,1})([0-9A-F]{8}|[0-9A-F]{6})$/gi;
|
||||
return hexRegex.test(color);
|
||||
};
|
||||
|
||||
export const getColorName = (color?: string, theme?: GrafanaTheme): Color | undefined => {
|
||||
if (!color) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (color.indexOf('rgb') > -1) {
|
||||
return undefined;
|
||||
}
|
||||
if (isHex(color)) {
|
||||
const definition = getColorDefinition(color, theme || GrafanaTheme.Dark);
|
||||
return definition ? definition.name : undefined;
|
||||
}
|
||||
|
||||
return color as Color;
|
||||
};
|
||||
|
||||
export const getColorByName = (colorName: string) => {
|
||||
const definition = flatten(Array.from(getNamedColorPalette().values())).filter(definition => definition.name === colorName);
|
||||
return definition.length > 0 ? definition[0] : undefined;
|
||||
};
|
||||
|
||||
export const getColorFromHexRgbOrName = (color: string, theme?: GrafanaTheme): string => {
|
||||
if (color.indexOf('rgb') > -1 || isHex(color)) {
|
||||
return color;
|
||||
}
|
||||
|
||||
const colorDefinition = getColorByName(color);
|
||||
|
||||
if (!colorDefinition) {
|
||||
throw new Error('Unknown color');
|
||||
}
|
||||
|
||||
return theme ? colorDefinition.variants[theme] : colorDefinition.variants.dark;
|
||||
};
|
||||
|
||||
export const getColorForTheme = (color: ColorDefinition, theme?: GrafanaTheme) => {
|
||||
return theme ? color.variants[theme] : color.variants.dark;
|
||||
};
|
||||
|
||||
const buildNamedColorsPalette = () => {
|
||||
const palette = new Map<Hue, ColorDefinition[]>();
|
||||
|
||||
const BasicGreen = buildColorDefinition('green', 'green', ['#56A64B', '#73BF69'], true);
|
||||
const DarkGreen = buildColorDefinition('green', 'dark-green', ['#19730E', '#37872D']);
|
||||
const SemiDarkGreen = buildColorDefinition('green', 'semi-dark-green', ['#37872D', '#56A64B']);
|
||||
const LightGreen = buildColorDefinition('green', 'light-green', ['#73BF69', '#96D98D']);
|
||||
const SuperLightGreen = buildColorDefinition('green', 'super-light-green', ['#96D98D', '#C8F2C2']);
|
||||
|
||||
const BasicYellow = buildColorDefinition('yellow', 'yellow', ['#F2CC0C', '#FADE2A'], true);
|
||||
const DarkYellow = buildColorDefinition('yellow', 'dark-yellow', ['#CC9D00', '#E0B400']);
|
||||
const SemiDarkYellow = buildColorDefinition('yellow', 'semi-dark-yellow', ['#E0B400', '#F2CC0C']);
|
||||
const LightYellow = buildColorDefinition('yellow', 'light-yellow', ['#FADE2A', '#FFEE52']);
|
||||
const SuperLightYellow = buildColorDefinition('yellow', 'super-light-yellow', ['#FFEE52', '#FFF899']);
|
||||
|
||||
const BasicRed = buildColorDefinition('red', 'red', ['#E02F44', '#F2495C'], true);
|
||||
const DarkRed = buildColorDefinition('red', 'dark-red', ['#AD0317', '#C4162A']);
|
||||
const SemiDarkRed = buildColorDefinition('red', 'semi-dark-red', ['#C4162A', '#E02F44']);
|
||||
const LightRed = buildColorDefinition('red', 'light-red', ['#F2495C', '#FF7383']);
|
||||
const SuperLightRed = buildColorDefinition('red', 'super-light-red', ['#FF7383', '#FFA6B0']);
|
||||
|
||||
const BasicBlue = buildColorDefinition('blue', 'blue', ['#3274D9', '#5794F2'], true);
|
||||
const DarkBlue = buildColorDefinition('blue', 'dark-blue', ['#1250B0', '#1F60C4']);
|
||||
const SemiDarkBlue = buildColorDefinition('blue', 'semi-dark-blue', ['#1F60C4', '#3274D9']);
|
||||
const LightBlue = buildColorDefinition('blue', 'light-blue', ['#5794F2', '#8AB8FF']);
|
||||
const SuperLightBlue = buildColorDefinition('blue', 'super-light-blue', ['#8AB8FF', '#C0D8FF']);
|
||||
|
||||
const BasicOrange = buildColorDefinition('orange', 'orange', ['#FF780A', '#FF9830'], true);
|
||||
const DarkOrange = buildColorDefinition('orange', 'dark-orange', ['#E55400', '#FA6400']);
|
||||
const SemiDarkOrange = buildColorDefinition('orange', 'semi-dark-orange', ['#FA6400', '#FF780A']);
|
||||
const LightOrange = buildColorDefinition('orange', 'light-orange', ['#FF9830', '#FFB357']);
|
||||
const SuperLightOrange = buildColorDefinition('orange', 'super-light-orange', ['#FFB357', '#FFCB7D']);
|
||||
|
||||
const BasicPurple = buildColorDefinition('purple', 'purple', ['#A352CC', '#B877D9'], true);
|
||||
const DarkPurple = buildColorDefinition('purple', 'dark-purple', ['#7C2EA3', '#8F3BB8']);
|
||||
const SemiDarkPurple = buildColorDefinition('purple', 'semi-dark-purple', ['#8F3BB8', '#A352CC']);
|
||||
const LightPurple = buildColorDefinition('purple', 'light-purple', ['#B877D9', '#CA95E5']);
|
||||
const SuperLightPurple = buildColorDefinition('purple', 'super-light-purple', ['#CA95E5', '#DEB6F2']);
|
||||
|
||||
const greens = [BasicGreen, DarkGreen, SemiDarkGreen, LightGreen, SuperLightGreen];
|
||||
const yellows = [BasicYellow, DarkYellow, SemiDarkYellow, LightYellow, SuperLightYellow];
|
||||
const reds = [BasicRed, DarkRed, SemiDarkRed, LightRed, SuperLightRed];
|
||||
const blues = [BasicBlue, DarkBlue, SemiDarkBlue, LightBlue, SuperLightBlue];
|
||||
const oranges = [BasicOrange, DarkOrange, SemiDarkOrange, LightOrange, SuperLightOrange];
|
||||
const purples = [BasicPurple, DarkPurple, SemiDarkPurple, LightPurple, SuperLightPurple];
|
||||
|
||||
palette.set('green', greens);
|
||||
palette.set('yellow', yellows);
|
||||
palette.set('red', reds);
|
||||
palette.set('blue', blues);
|
||||
palette.set('orange', oranges);
|
||||
palette.set('purple', purples);
|
||||
|
||||
return palette;
|
||||
};
|
||||
|
||||
export const getNamedColorPalette = () => {
|
||||
if (colorsPaletteInstance) {
|
||||
return colorsPaletteInstance;
|
||||
}
|
||||
|
||||
colorsPaletteInstance = buildNamedColorsPalette();
|
||||
return colorsPaletteInstance;
|
||||
};
|
6
packages/grafana-ui/src/utils/propDeprecationWarning.ts
Normal file
6
packages/grafana-ui/src/utils/propDeprecationWarning.ts
Normal file
@ -0,0 +1,6 @@
|
||||
const propDeprecationWarning = (componentName: string, propName: string, newPropName: string) => {
|
||||
const message = `[Deprecation warning] ${componentName}: ${propName} is deprecated. Use ${newPropName} instead`;
|
||||
console.warn(message);
|
||||
};
|
||||
|
||||
export default propDeprecationWarning;
|
38
packages/grafana-ui/src/utils/storybook/UseState.tsx
Normal file
38
packages/grafana-ui/src/utils/storybook/UseState.tsx
Normal file
@ -0,0 +1,38 @@
|
||||
import React from 'react';
|
||||
|
||||
interface StateHolderProps<T> {
|
||||
initialState: T;
|
||||
children: (currentState: T, updateState: (nextState: T) => void) => JSX.Element;
|
||||
}
|
||||
|
||||
export class UseState<T> extends React.Component<StateHolderProps<T>, { value: T; initialState: T }> {
|
||||
constructor(props: StateHolderProps<T>) {
|
||||
super(props);
|
||||
this.state = {
|
||||
value: props.initialState,
|
||||
initialState: props.initialState, // To enable control from knobs
|
||||
};
|
||||
}
|
||||
// @ts-ignore
|
||||
static getDerivedStateFromProps(props: StateHolderProps<{}>, state: { value: any; initialState: any }) {
|
||||
if (props.initialState !== state.initialState) {
|
||||
return {
|
||||
initialState: props.initialState,
|
||||
value: props.initialState,
|
||||
};
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
value: state.value,
|
||||
};
|
||||
}
|
||||
|
||||
handleStateUpdate = (nextState: T) => {
|
||||
console.log(nextState);
|
||||
this.setState({ value: nextState });
|
||||
};
|
||||
|
||||
render() {
|
||||
return this.props.children(this.state.value, this.handleStateUpdate);
|
||||
}
|
||||
}
|
14
packages/grafana-ui/src/utils/storybook/themeKnob.ts
Normal file
14
packages/grafana-ui/src/utils/storybook/themeKnob.ts
Normal file
@ -0,0 +1,14 @@
|
||||
import { select } from '@storybook/addon-knobs';
|
||||
import { GrafanaTheme } from '../../types';
|
||||
|
||||
export const getThemeKnob = (defaultTheme: GrafanaTheme = GrafanaTheme.Dark) => {
|
||||
return select(
|
||||
'Theme',
|
||||
{
|
||||
Default: defaultTheme,
|
||||
Light: GrafanaTheme.Light,
|
||||
Dark: GrafanaTheme.Dark,
|
||||
},
|
||||
defaultTheme
|
||||
);
|
||||
};
|
@ -0,0 +1,19 @@
|
||||
import React from 'react';
|
||||
import { RenderFunction } from '@storybook/react';
|
||||
|
||||
const CenteredStory: React.FunctionComponent<{}> = ({ children }) => {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
height: '100vh ',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const withCenteredStory = (story: RenderFunction) => <CenteredStory>{story()}</CenteredStory>;
|
509
packages/grafana-ui/src/vendor/spectrum.css
vendored
509
packages/grafana-ui/src/vendor/spectrum.css
vendored
@ -1,509 +0,0 @@
|
||||
/***
|
||||
Spectrum Colorpicker v1.3.0
|
||||
https://github.com/bgrins/spectrum
|
||||
Author: Brian Grinstead
|
||||
License: MIT
|
||||
***/
|
||||
|
||||
.sp-container {
|
||||
position:absolute;
|
||||
top:0;
|
||||
left:0;
|
||||
display:inline-block;
|
||||
*display: inline;
|
||||
*zoom: 1;
|
||||
/* https://github.com/bgrins/spectrum/issues/40 */
|
||||
z-index: 9999994;
|
||||
overflow: hidden;
|
||||
}
|
||||
.sp-container.sp-flat {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Fix for * { box-sizing: border-box; } */
|
||||
.sp-container,
|
||||
.sp-container * {
|
||||
-webkit-box-sizing: content-box;
|
||||
-moz-box-sizing: content-box;
|
||||
box-sizing: content-box;
|
||||
}
|
||||
|
||||
/* http://ansciath.tumblr.com/post/7347495869/css-aspect-ratio */
|
||||
.sp-top {
|
||||
position:relative;
|
||||
width: 100%;
|
||||
display:inline-block;
|
||||
}
|
||||
.sp-top-inner {
|
||||
position:absolute;
|
||||
top:0;
|
||||
left:0;
|
||||
bottom:0;
|
||||
right:0;
|
||||
}
|
||||
.sp-color {
|
||||
position: absolute;
|
||||
top:0;
|
||||
left:0;
|
||||
bottom:0;
|
||||
right:20%;
|
||||
}
|
||||
.sp-hue {
|
||||
position: absolute;
|
||||
top:0;
|
||||
right:0;
|
||||
bottom:0;
|
||||
left:84%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.sp-clear-enabled .sp-hue {
|
||||
top:33px;
|
||||
height: 77.5%;
|
||||
}
|
||||
|
||||
.sp-fill {
|
||||
padding-top: 80%;
|
||||
}
|
||||
.sp-sat, .sp-val {
|
||||
position: absolute;
|
||||
top:0;
|
||||
left:0;
|
||||
right:0;
|
||||
bottom:0;
|
||||
}
|
||||
|
||||
.sp-alpha-enabled .sp-top {
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
.sp-alpha-enabled .sp-alpha {
|
||||
display: block;
|
||||
}
|
||||
.sp-alpha-handle {
|
||||
position:absolute;
|
||||
top:-4px;
|
||||
bottom: -4px;
|
||||
width: 6px;
|
||||
left: 50%;
|
||||
cursor: pointer;
|
||||
border: 1px solid black;
|
||||
background: white;
|
||||
opacity: .8;
|
||||
}
|
||||
.sp-alpha {
|
||||
display: none;
|
||||
position: absolute;
|
||||
bottom: -14px;
|
||||
right: 0;
|
||||
left: 0;
|
||||
height: 8px;
|
||||
}
|
||||
.sp-alpha-inner {
|
||||
border: solid 1px #333;
|
||||
}
|
||||
|
||||
.sp-clear {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.sp-clear.sp-clear-display {
|
||||
background-position: center;
|
||||
}
|
||||
|
||||
.sp-clear-enabled .sp-clear {
|
||||
display: block;
|
||||
position:absolute;
|
||||
top:0px;
|
||||
right:0;
|
||||
bottom:0;
|
||||
left:84%;
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
/* Don't allow text selection */
|
||||
.sp-container, .sp-replacer, .sp-preview, .sp-dragger, .sp-slider, .sp-alpha, .sp-clear, .sp-alpha-handle, .sp-container.sp-dragging .sp-input, .sp-container button {
|
||||
-webkit-user-select:none;
|
||||
-moz-user-select: -moz-none;
|
||||
-o-user-select:none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.sp-container.sp-input-disabled .sp-input-container {
|
||||
display: none;
|
||||
}
|
||||
.sp-container.sp-buttons-disabled .sp-button-container {
|
||||
display: none;
|
||||
}
|
||||
.sp-palette-only .sp-picker-container {
|
||||
display: none;
|
||||
}
|
||||
.sp-palette-disabled .sp-palette-container {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.sp-initial-disabled .sp-initial {
|
||||
display: none;
|
||||
}
|
||||
|
||||
|
||||
/* Gradients for hue, saturation and value instead of images. Not pretty... but it works */
|
||||
.sp-sat {
|
||||
background-image: linear-gradient(to right, #fff, rgba(204, 154, 129, 0));
|
||||
-ms-filter: "progid:DXImageTransform.Microsoft.gradient(GradientType = 1, startColorstr=#FFFFFFFF, endColorstr=#00CC9A81)";
|
||||
filter : progid:DXImageTransform.Microsoft.gradient(GradientType = 1, startColorstr='#FFFFFFFF', endColorstr='#00CC9A81');
|
||||
}
|
||||
.sp-val {
|
||||
background-image: linear-gradient(to top, #000, rgba(204, 154, 129, 0));
|
||||
-ms-filter: "progid:DXImageTransform.Microsoft.gradient(startColorstr=#00CC9A81, endColorstr=#FF000000)";
|
||||
filter : progid:DXImageTransform.Microsoft.gradient(startColorstr='#00CC9A81', endColorstr='#FF000000');
|
||||
}
|
||||
|
||||
.sp-hue {
|
||||
background: -moz-linear-gradient(top, #ff0000 0%, #ffff00 17%, #00ff00 33%, #00ffff 50%, #0000ff 67%, #ff00ff 83%, #ff0000 100%);
|
||||
background: -ms-linear-gradient(top, #ff0000 0%, #ffff00 17%, #00ff00 33%, #00ffff 50%, #0000ff 67%, #ff00ff 83%, #ff0000 100%);
|
||||
background: -o-linear-gradient(top, #ff0000 0%, #ffff00 17%, #00ff00 33%, #00ffff 50%, #0000ff 67%, #ff00ff 83%, #ff0000 100%);
|
||||
background: -webkit-gradient(linear, left top, left bottom, from(#ff0000), color-stop(0.17, #ffff00), color-stop(0.33, #00ff00), color-stop(0.5, #00ffff), color-stop(0.67, #0000ff), color-stop(0.83, #ff00ff), to(#ff0000));
|
||||
background: -webkit-linear-gradient(top, #ff0000 0%, #ffff00 17%, #00ff00 33%, #00ffff 50%, #0000ff 67%, #ff00ff 83%, #ff0000 100%);
|
||||
}
|
||||
|
||||
/* IE filters do not support multiple color stops.
|
||||
Generate 6 divs, line them up, and do two color gradients for each.
|
||||
Yes, really.
|
||||
*/
|
||||
.sp-1 {
|
||||
height:17%;
|
||||
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff0000', endColorstr='#ffff00');
|
||||
}
|
||||
.sp-2 {
|
||||
height:16%;
|
||||
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffff00', endColorstr='#00ff00');
|
||||
}
|
||||
.sp-3 {
|
||||
height:17%;
|
||||
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#00ff00', endColorstr='#00ffff');
|
||||
}
|
||||
.sp-4 {
|
||||
height:17%;
|
||||
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#00ffff', endColorstr='#0000ff');
|
||||
}
|
||||
.sp-5 {
|
||||
height:16%;
|
||||
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#0000ff', endColorstr='#ff00ff');
|
||||
}
|
||||
.sp-6 {
|
||||
height:17%;
|
||||
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff00ff', endColorstr='#ff0000');
|
||||
}
|
||||
|
||||
.sp-hidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* Clearfix hack */
|
||||
.sp-cf:before, .sp-cf:after { content: ""; display: table; }
|
||||
.sp-cf:after { clear: both; }
|
||||
.sp-cf { *zoom: 1; }
|
||||
|
||||
/* Mobile devices, make hue slider bigger so it is easier to slide */
|
||||
@media (max-device-width: 480px) {
|
||||
.sp-color { right: 40%; }
|
||||
.sp-hue { left: 63%; }
|
||||
.sp-fill { padding-top: 60%; }
|
||||
}
|
||||
.sp-dragger {
|
||||
border-radius: 5px;
|
||||
height: 5px;
|
||||
width: 5px;
|
||||
border: 1px solid #fff;
|
||||
background: #000;
|
||||
cursor: pointer;
|
||||
position:absolute;
|
||||
top:0;
|
||||
left: 0;
|
||||
}
|
||||
.sp-slider {
|
||||
position: absolute;
|
||||
top:0;
|
||||
cursor:pointer;
|
||||
height: 3px;
|
||||
left: -1px;
|
||||
right: -1px;
|
||||
border: 1px solid #000;
|
||||
background: white;
|
||||
opacity: .8;
|
||||
}
|
||||
|
||||
/*
|
||||
Theme authors:
|
||||
Here are the basic themeable display options (colors, fonts, global widths).
|
||||
See http://bgrins.github.io/spectrum/themes/ for instructions.
|
||||
*/
|
||||
|
||||
.sp-container {
|
||||
border-radius: 0;
|
||||
background-color: #ECECEC;
|
||||
border: solid 1px #f0c49B;
|
||||
padding: 0;
|
||||
}
|
||||
.sp-container, .sp-container button, .sp-container input, .sp-color, .sp-hue, .sp-clear
|
||||
{
|
||||
font: normal 12px "Lucida Grande", "Lucida Sans Unicode", "Lucida Sans", Geneva, Verdana, sans-serif;
|
||||
-webkit-box-sizing: border-box;
|
||||
-moz-box-sizing: border-box;
|
||||
-ms-box-sizing: border-box;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.sp-top
|
||||
{
|
||||
margin-bottom: 3px;
|
||||
}
|
||||
.sp-color, .sp-hue, .sp-clear
|
||||
{
|
||||
border: solid 1px #666;
|
||||
}
|
||||
|
||||
/* Input */
|
||||
.sp-input-container {
|
||||
float:right;
|
||||
width: 100px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.sp-initial-disabled .sp-input-container {
|
||||
width: 100%;
|
||||
}
|
||||
.sp-input {
|
||||
font-size: 12px !important;
|
||||
border: 1px inset;
|
||||
padding: 4px 5px;
|
||||
margin: 0;
|
||||
width: 100%;
|
||||
background:transparent;
|
||||
border-radius: 3px;
|
||||
color: #222;
|
||||
}
|
||||
.sp-input:focus {
|
||||
border: 1px solid orange;
|
||||
}
|
||||
.sp-input.sp-validation-error
|
||||
{
|
||||
border: 1px solid red;
|
||||
background: #fdd;
|
||||
}
|
||||
.sp-picker-container , .sp-palette-container
|
||||
{
|
||||
float:left;
|
||||
position: relative;
|
||||
padding: 10px;
|
||||
padding-bottom: 300px;
|
||||
margin-bottom: -290px;
|
||||
}
|
||||
.sp-picker-container
|
||||
{
|
||||
width: 172px;
|
||||
border-left: solid 1px #fff;
|
||||
}
|
||||
|
||||
/* Palettes */
|
||||
.sp-palette-container
|
||||
{
|
||||
border-right: solid 1px #ccc;
|
||||
}
|
||||
|
||||
.sp-palette .sp-thumb-el {
|
||||
display: block;
|
||||
position:relative;
|
||||
float:left;
|
||||
width: 24px;
|
||||
height: 15px;
|
||||
margin: 3px;
|
||||
cursor: pointer;
|
||||
border:solid 2px transparent;
|
||||
}
|
||||
.sp-palette .sp-thumb-el:hover, .sp-palette .sp-thumb-el.sp-thumb-active {
|
||||
border-color: orange;
|
||||
}
|
||||
.sp-thumb-el
|
||||
{
|
||||
position:relative;
|
||||
}
|
||||
|
||||
/* Initial */
|
||||
.sp-initial
|
||||
{
|
||||
float: left;
|
||||
border: solid 1px #333;
|
||||
}
|
||||
.sp-initial span {
|
||||
width: 30px;
|
||||
height: 25px;
|
||||
border:none;
|
||||
display:block;
|
||||
float:left;
|
||||
margin:0;
|
||||
}
|
||||
|
||||
.sp-initial .sp-clear-display {
|
||||
background-position: center;
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
.sp-button-container {
|
||||
float: right;
|
||||
}
|
||||
|
||||
/* Replacer (the little preview div that shows up instead of the <input>) */
|
||||
.sp-replacer {
|
||||
margin:0;
|
||||
overflow:hidden;
|
||||
cursor:pointer;
|
||||
padding: 4px;
|
||||
display:inline-block;
|
||||
*zoom: 1;
|
||||
*display: inline;
|
||||
border: solid 1px #91765d;
|
||||
background: #eee;
|
||||
color: #333;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.sp-replacer:hover, .sp-replacer.sp-active {
|
||||
border-color: #F0C49B;
|
||||
color: #111;
|
||||
}
|
||||
.sp-replacer.sp-disabled {
|
||||
cursor:default;
|
||||
border-color: silver;
|
||||
color: silver;
|
||||
}
|
||||
.sp-dd {
|
||||
padding: 2px 0;
|
||||
height: 16px;
|
||||
line-height: 16px;
|
||||
float:left;
|
||||
font-size:10px;
|
||||
}
|
||||
.sp-preview
|
||||
{
|
||||
position:relative;
|
||||
width:25px;
|
||||
height: 20px;
|
||||
border: solid 1px #222;
|
||||
margin-right: 5px;
|
||||
float:left;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.sp-palette
|
||||
{
|
||||
*width: 220px;
|
||||
max-width: 220px;
|
||||
}
|
||||
.sp-palette .sp-thumb-el
|
||||
{
|
||||
width:16px;
|
||||
height: 16px;
|
||||
margin:2px 1px;
|
||||
border: solid 1px #d0d0d0;
|
||||
}
|
||||
|
||||
.sp-container
|
||||
{
|
||||
padding-bottom:0;
|
||||
}
|
||||
|
||||
|
||||
/* Buttons: http://hellohappy.org/css3-buttons/ */
|
||||
.sp-container button {
|
||||
background-color: #eeeeee;
|
||||
background-image: -webkit-linear-gradient(top, #eeeeee, #cccccc);
|
||||
background-image: -moz-linear-gradient(top, #eeeeee, #cccccc);
|
||||
background-image: -ms-linear-gradient(top, #eeeeee, #cccccc);
|
||||
background-image: -o-linear-gradient(top, #eeeeee, #cccccc);
|
||||
background-image: linear-gradient(to bottom, #eeeeee, #cccccc);
|
||||
border: 1px solid #ccc;
|
||||
border-bottom: 1px solid #bbb;
|
||||
border-radius: 3px;
|
||||
color: #333;
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
padding: 5px 4px;
|
||||
text-align: center;
|
||||
text-shadow: 0 1px 0 #eee;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.sp-container button:hover {
|
||||
background-color: #dddddd;
|
||||
background-image: -webkit-linear-gradient(top, #dddddd, #bbbbbb);
|
||||
background-image: -moz-linear-gradient(top, #dddddd, #bbbbbb);
|
||||
background-image: -ms-linear-gradient(top, #dddddd, #bbbbbb);
|
||||
background-image: -o-linear-gradient(top, #dddddd, #bbbbbb);
|
||||
background-image: linear-gradient(to bottom, #dddddd, #bbbbbb);
|
||||
border: 1px solid #bbb;
|
||||
border-bottom: 1px solid #999;
|
||||
cursor: pointer;
|
||||
text-shadow: 0 1px 0 #ddd;
|
||||
}
|
||||
.sp-container button:active {
|
||||
border: 1px solid #aaa;
|
||||
border-bottom: 1px solid #888;
|
||||
-webkit-box-shadow: inset 0 0 5px 2px #aaaaaa, 0 1px 0 0 #eeeeee;
|
||||
-moz-box-shadow: inset 0 0 5px 2px #aaaaaa, 0 1px 0 0 #eeeeee;
|
||||
-ms-box-shadow: inset 0 0 5px 2px #aaaaaa, 0 1px 0 0 #eeeeee;
|
||||
-o-box-shadow: inset 0 0 5px 2px #aaaaaa, 0 1px 0 0 #eeeeee;
|
||||
box-shadow: inset 0 0 5px 2px #aaaaaa, 0 1px 0 0 #eeeeee;
|
||||
}
|
||||
.sp-cancel
|
||||
{
|
||||
font-size: 11px;
|
||||
color: #d93f3f !important;
|
||||
margin:0;
|
||||
padding:2px;
|
||||
margin-right: 5px;
|
||||
vertical-align: middle;
|
||||
text-decoration:none;
|
||||
|
||||
}
|
||||
.sp-cancel:hover
|
||||
{
|
||||
color: #d93f3f !important;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
|
||||
.sp-palette span:hover, .sp-palette span.sp-thumb-active
|
||||
{
|
||||
border-color: #000;
|
||||
}
|
||||
|
||||
.sp-preview, .sp-alpha, .sp-thumb-el
|
||||
{
|
||||
position:relative;
|
||||
background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAwAAAAMCAIAAADZF8uwAAAAGUlEQVQYV2M4gwH+YwCGIasIUwhT25BVBADtzYNYrHvv4gAAAABJRU5ErkJggg==);
|
||||
}
|
||||
.sp-preview-inner, .sp-alpha-inner, .sp-thumb-inner
|
||||
{
|
||||
display:block;
|
||||
position:absolute;
|
||||
top:0;left:0;bottom:0;right:0;
|
||||
}
|
||||
|
||||
.sp-palette .sp-thumb-inner
|
||||
{
|
||||
background-position: 50% 50%;
|
||||
background-repeat: no-repeat;
|
||||
}
|
||||
|
||||
.sp-palette .sp-thumb-light.sp-thumb-active .sp-thumb-inner
|
||||
{
|
||||
background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABIAAAASCAYAAABWzo5XAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAAIVJREFUeNpiYBhsgJFMffxAXABlN5JruT4Q3wfi/0DsT64h8UD8HmpIPCWG/KemIfOJCUB+Aoacx6EGBZyHBqI+WsDCwuQ9mhxeg2A210Ntfo8klk9sOMijaURm7yc1UP2RNCMbKE9ODK1HM6iegYLkfx8pligC9lCD7KmRof0ZhjQACDAAceovrtpVBRkAAAAASUVORK5CYII=);
|
||||
}
|
||||
|
||||
.sp-palette .sp-thumb-dark.sp-thumb-active .sp-thumb-inner
|
||||
{
|
||||
background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABIAAAASCAYAAABWzo5XAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsMAAA7DAcdvqGQAAAAadEVYdFNvZnR3YXJlAFBhaW50Lk5FVCB2My41LjEwMPRyoQAAAMdJREFUOE+tkgsNwzAMRMugEAahEAahEAZhEAqlEAZhEAohEAYh81X2dIm8fKpEspLGvudPOsUYpxE2BIJCroJmEW9qJ+MKaBFhEMNabSy9oIcIPwrB+afvAUFoK4H0tMaQ3XtlrggDhOVVMuT4E5MMG0FBbCEYzjYT7OxLEvIHQLY2zWwQ3D+9luyOQTfKDiFD3iUIfPk8VqrKjgAiSfGFPecrg6HN6m/iBcwiDAo7WiBeawa+Kwh7tZoSCGLMqwlSAzVDhoK+6vH4G0P5wdkAAAAASUVORK5CYII=);
|
||||
}
|
||||
|
||||
.sp-clear-display {
|
||||
background-repeat:no-repeat;
|
||||
background-position: center;
|
||||
background-image: url(data:image/gif;base64,R0lGODlhFAAUAPcAAAAAAJmZmZ2dnZ6enqKioqOjo6SkpKWlpaampqenp6ioqKmpqaqqqqurq/Hx8fLy8vT09PX19ff39/j4+Pn5+fr6+vv7+wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACH5BAEAAP8ALAAAAAAUABQAAAihAP9FoPCvoMGDBy08+EdhQAIJCCMybCDAAYUEARBAlFiQQoMABQhKUJBxY0SPICEYHBnggEmDKAuoPMjS5cGYMxHW3IiT478JJA8M/CjTZ0GgLRekNGpwAsYABHIypcAgQMsITDtWJYBR6NSqMico9cqR6tKfY7GeBCuVwlipDNmefAtTrkSzB1RaIAoXodsABiZAEFB06gIBWC1mLVgBa0AAOw==);
|
||||
}
|
2317
packages/grafana-ui/src/vendor/spectrum.js
vendored
2317
packages/grafana-ui/src/vendor/spectrum.js
vendored
File diff suppressed because it is too large
Load Diff
@ -5,14 +5,17 @@
|
||||
"src/**/*.tsx"
|
||||
],
|
||||
"exclude": [
|
||||
"dist"
|
||||
"dist",
|
||||
"node_modules"
|
||||
],
|
||||
"compilerOptions": {
|
||||
"rootDir": ".",
|
||||
"rootDirs": [".", "stories"],
|
||||
"module": "esnext",
|
||||
"outDir": "dist",
|
||||
"declaration": true,
|
||||
"noImplicitAny": true,
|
||||
"strictNullChecks": true
|
||||
}
|
||||
"strictNullChecks": true,
|
||||
"typeRoots": ["./node_modules/@types", "types"],
|
||||
"skipLibCheck": true // Temp workaround for Duplicate identifier tsc errors
|
||||
},
|
||||
}
|
||||
|
@ -50,6 +50,7 @@ func formatShort(interval time.Duration) string {
|
||||
func NewAlertNotification(notification *models.AlertNotification) *AlertNotification {
|
||||
return &AlertNotification{
|
||||
Id: notification.Id,
|
||||
Uid: notification.Uid,
|
||||
Name: notification.Name,
|
||||
Type: notification.Type,
|
||||
IsDefault: notification.IsDefault,
|
||||
@ -64,6 +65,7 @@ func NewAlertNotification(notification *models.AlertNotification) *AlertNotifica
|
||||
|
||||
type AlertNotification struct {
|
||||
Id int64 `json:"id"`
|
||||
Uid string `json:"uid"`
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
IsDefault bool `json:"isDefault"`
|
||||
|
@ -8,10 +8,11 @@ import (
|
||||
)
|
||||
|
||||
var (
|
||||
ErrNotificationFrequencyNotFound = errors.New("Notification frequency not specified")
|
||||
ErrAlertNotificationStateNotFound = errors.New("alert notification state not found")
|
||||
ErrAlertNotificationStateVersionConflict = errors.New("alert notification state update version conflict")
|
||||
ErrAlertNotificationStateAlreadyExist = errors.New("alert notification state already exists.")
|
||||
ErrNotificationFrequencyNotFound = errors.New("Notification frequency not specified")
|
||||
ErrAlertNotificationStateNotFound = errors.New("alert notification state not found")
|
||||
ErrAlertNotificationStateVersionConflict = errors.New("alert notification state update version conflict")
|
||||
ErrAlertNotificationStateAlreadyExist = errors.New("alert notification state already exists.")
|
||||
ErrAlertNotificationFailedGenerateUniqueUid = errors.New("Failed to generate unique alert notification uid")
|
||||
)
|
||||
|
||||
type AlertNotificationStateType string
|
||||
@ -24,6 +25,7 @@ var (
|
||||
|
||||
type AlertNotification struct {
|
||||
Id int64 `json:"id"`
|
||||
Uid string `json:"-"`
|
||||
OrgId int64 `json:"-"`
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
@ -37,6 +39,7 @@ type AlertNotification struct {
|
||||
}
|
||||
|
||||
type CreateAlertNotificationCommand struct {
|
||||
Uid string `json:"-"`
|
||||
Name string `json:"name" binding:"Required"`
|
||||
Type string `json:"type" binding:"Required"`
|
||||
SendReminder bool `json:"sendReminder"`
|
||||
@ -63,10 +66,28 @@ type UpdateAlertNotificationCommand struct {
|
||||
Result *AlertNotification
|
||||
}
|
||||
|
||||
type UpdateAlertNotificationWithUidCommand struct {
|
||||
Uid string
|
||||
Name string
|
||||
Type string
|
||||
SendReminder bool
|
||||
DisableResolveMessage bool
|
||||
Frequency string
|
||||
IsDefault bool
|
||||
Settings *simplejson.Json
|
||||
|
||||
OrgId int64
|
||||
Result *AlertNotification
|
||||
}
|
||||
|
||||
type DeleteAlertNotificationCommand struct {
|
||||
Id int64
|
||||
OrgId int64
|
||||
}
|
||||
type DeleteAlertNotificationWithUidCommand struct {
|
||||
Uid string
|
||||
OrgId int64
|
||||
}
|
||||
|
||||
type GetAlertNotificationsQuery struct {
|
||||
Name string
|
||||
@ -76,8 +97,15 @@ type GetAlertNotificationsQuery struct {
|
||||
Result *AlertNotification
|
||||
}
|
||||
|
||||
type GetAlertNotificationsToSendQuery struct {
|
||||
Ids []int64
|
||||
type GetAlertNotificationsWithUidQuery struct {
|
||||
Uid string
|
||||
OrgId int64
|
||||
|
||||
Result *AlertNotification
|
||||
}
|
||||
|
||||
type GetAlertNotificationsWithUidToSendQuery struct {
|
||||
Uids []string
|
||||
OrgId int64
|
||||
|
||||
Result []*AlertNotification
|
||||
|
@ -8,6 +8,7 @@ import (
|
||||
"github.com/grafana/grafana/pkg/bus"
|
||||
"github.com/grafana/grafana/pkg/components/simplejson"
|
||||
m "github.com/grafana/grafana/pkg/models"
|
||||
"github.com/grafana/grafana/pkg/services/sqlstore"
|
||||
. "github.com/smartystreets/goconvey/convey"
|
||||
)
|
||||
|
||||
@ -197,74 +198,84 @@ func TestAlertRuleExtraction(t *testing.T) {
|
||||
})
|
||||
})
|
||||
|
||||
Convey("Parse and validate dashboard containing influxdb alert", func() {
|
||||
json, err := ioutil.ReadFile("./testdata/influxdb-alert.json")
|
||||
Convey("Alert notifications are in DB", func() {
|
||||
sqlstore.InitTestDB(t)
|
||||
firstNotification := m.CreateAlertNotificationCommand{Uid: "notifier1", OrgId: 1, Name: "1"}
|
||||
err = sqlstore.CreateAlertNotificationCommand(&firstNotification)
|
||||
So(err, ShouldBeNil)
|
||||
secondNotification := m.CreateAlertNotificationCommand{Uid: "notifier2", OrgId: 1, Name: "2"}
|
||||
err = sqlstore.CreateAlertNotificationCommand(&secondNotification)
|
||||
So(err, ShouldBeNil)
|
||||
|
||||
dashJson, err := simplejson.NewJson(json)
|
||||
So(err, ShouldBeNil)
|
||||
dash := m.NewDashboardFromJson(dashJson)
|
||||
extractor := NewDashAlertExtractor(dash, 1, nil)
|
||||
|
||||
alerts, err := extractor.GetAlerts()
|
||||
|
||||
Convey("Get rules without error", func() {
|
||||
Convey("Parse and validate dashboard containing influxdb alert", func() {
|
||||
json, err := ioutil.ReadFile("./testdata/influxdb-alert.json")
|
||||
So(err, ShouldBeNil)
|
||||
})
|
||||
|
||||
Convey("should be able to read interval", func() {
|
||||
So(len(alerts), ShouldEqual, 1)
|
||||
|
||||
for _, alert := range alerts {
|
||||
So(alert.DashboardId, ShouldEqual, 4)
|
||||
|
||||
conditions := alert.Settings.Get("conditions").MustArray()
|
||||
cond := simplejson.NewFromAny(conditions[0])
|
||||
|
||||
So(cond.Get("query").Get("model").Get("interval").MustString(), ShouldEqual, ">10s")
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
Convey("Should be able to extract collapsed panels", func() {
|
||||
json, err := ioutil.ReadFile("./testdata/collapsed-panels.json")
|
||||
So(err, ShouldBeNil)
|
||||
|
||||
dashJson, err := simplejson.NewJson(json)
|
||||
So(err, ShouldBeNil)
|
||||
|
||||
dash := m.NewDashboardFromJson(dashJson)
|
||||
extractor := NewDashAlertExtractor(dash, 1, nil)
|
||||
|
||||
alerts, err := extractor.GetAlerts()
|
||||
|
||||
Convey("Get rules without error", func() {
|
||||
dashJson, err := simplejson.NewJson(json)
|
||||
So(err, ShouldBeNil)
|
||||
dash := m.NewDashboardFromJson(dashJson)
|
||||
extractor := NewDashAlertExtractor(dash, 1, nil)
|
||||
|
||||
alerts, err := extractor.GetAlerts()
|
||||
|
||||
Convey("Get rules without error", func() {
|
||||
So(err, ShouldBeNil)
|
||||
})
|
||||
|
||||
Convey("should be able to read interval", func() {
|
||||
So(len(alerts), ShouldEqual, 1)
|
||||
|
||||
for _, alert := range alerts {
|
||||
So(alert.DashboardId, ShouldEqual, 4)
|
||||
|
||||
conditions := alert.Settings.Get("conditions").MustArray()
|
||||
cond := simplejson.NewFromAny(conditions[0])
|
||||
|
||||
So(cond.Get("query").Get("model").Get("interval").MustString(), ShouldEqual, ">10s")
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
Convey("should be able to extract collapsed alerts", func() {
|
||||
So(len(alerts), ShouldEqual, 4)
|
||||
})
|
||||
})
|
||||
|
||||
Convey("Parse and validate dashboard without id and containing an alert", func() {
|
||||
json, err := ioutil.ReadFile("./testdata/dash-without-id.json")
|
||||
So(err, ShouldBeNil)
|
||||
|
||||
dashJSON, err := simplejson.NewJson(json)
|
||||
So(err, ShouldBeNil)
|
||||
dash := m.NewDashboardFromJson(dashJSON)
|
||||
extractor := NewDashAlertExtractor(dash, 1, nil)
|
||||
|
||||
err = extractor.ValidateAlerts()
|
||||
|
||||
Convey("Should validate without error", func() {
|
||||
Convey("Should be able to extract collapsed panels", func() {
|
||||
json, err := ioutil.ReadFile("./testdata/collapsed-panels.json")
|
||||
So(err, ShouldBeNil)
|
||||
|
||||
dashJson, err := simplejson.NewJson(json)
|
||||
So(err, ShouldBeNil)
|
||||
|
||||
dash := m.NewDashboardFromJson(dashJson)
|
||||
extractor := NewDashAlertExtractor(dash, 1, nil)
|
||||
|
||||
alerts, err := extractor.GetAlerts()
|
||||
|
||||
Convey("Get rules without error", func() {
|
||||
So(err, ShouldBeNil)
|
||||
})
|
||||
|
||||
Convey("should be able to extract collapsed alerts", func() {
|
||||
So(len(alerts), ShouldEqual, 4)
|
||||
})
|
||||
})
|
||||
|
||||
Convey("Should fail on save", func() {
|
||||
_, err := extractor.GetAlerts()
|
||||
So(err.Error(), ShouldEqual, "Alert validation error: Panel id is not correct, alertName=Influxdb, panelId=1")
|
||||
Convey("Parse and validate dashboard without id and containing an alert", func() {
|
||||
json, err := ioutil.ReadFile("./testdata/dash-without-id.json")
|
||||
So(err, ShouldBeNil)
|
||||
|
||||
dashJSON, err := simplejson.NewJson(json)
|
||||
So(err, ShouldBeNil)
|
||||
dash := m.NewDashboardFromJson(dashJSON)
|
||||
extractor := NewDashAlertExtractor(dash, 1, nil)
|
||||
|
||||
err = extractor.ValidateAlerts()
|
||||
|
||||
Convey("Should validate without error", func() {
|
||||
So(err, ShouldBeNil)
|
||||
})
|
||||
|
||||
Convey("Should fail on save", func() {
|
||||
_, err := extractor.GetAlerts()
|
||||
So(err.Error(), ShouldEqual, "Alert validation error: Panel id is not correct, alertName=Influxdb, panelId=1")
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
@ -24,7 +24,7 @@ type Notifier interface {
|
||||
// ShouldNotify checks this evaluation should send an alert notification
|
||||
ShouldNotify(ctx context.Context, evalContext *EvalContext, notificationState *models.AlertNotificationState) bool
|
||||
|
||||
GetNotifierId() int64
|
||||
GetNotifierUid() string
|
||||
GetIsDefault() bool
|
||||
GetSendReminder() bool
|
||||
GetDisableResolveMessage() bool
|
||||
|
@ -60,13 +60,13 @@ func (n *notificationService) SendIfNeeded(context *EvalContext) error {
|
||||
func (n *notificationService) sendAndMarkAsComplete(evalContext *EvalContext, notifierState *notifierState) error {
|
||||
notifier := notifierState.notifier
|
||||
|
||||
n.log.Debug("Sending notification", "type", notifier.GetType(), "id", notifier.GetNotifierId(), "isDefault", notifier.GetIsDefault())
|
||||
n.log.Debug("Sending notification", "type", notifier.GetType(), "uid", notifier.GetNotifierUid(), "isDefault", notifier.GetIsDefault())
|
||||
metrics.M_Alerting_Notification_Sent.WithLabelValues(notifier.GetType()).Inc()
|
||||
|
||||
err := notifier.Notify(evalContext)
|
||||
|
||||
if err != nil {
|
||||
n.log.Error("failed to send notification", "id", notifier.GetNotifierId(), "error", err)
|
||||
n.log.Error("failed to send notification", "uid", notifier.GetNotifierUid(), "error", err)
|
||||
}
|
||||
|
||||
if evalContext.IsTestRun {
|
||||
@ -110,7 +110,7 @@ func (n *notificationService) sendNotifications(evalContext *EvalContext, notifi
|
||||
for _, notifierState := range notifierStates {
|
||||
err := n.sendNotification(evalContext, notifierState)
|
||||
if err != nil {
|
||||
n.log.Error("failed to send notification", "id", notifierState.notifier.GetNotifierId(), "error", err)
|
||||
n.log.Error("failed to send notification", "uid", notifierState.notifier.GetNotifierUid(), "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
@ -157,8 +157,8 @@ func (n *notificationService) uploadImage(context *EvalContext) (err error) {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (n *notificationService) getNeededNotifiers(orgId int64, notificationIds []int64, evalContext *EvalContext) (notifierStateSlice, error) {
|
||||
query := &m.GetAlertNotificationsToSendQuery{OrgId: orgId, Ids: notificationIds}
|
||||
func (n *notificationService) getNeededNotifiers(orgId int64, notificationUids []string, evalContext *EvalContext) (notifierStateSlice, error) {
|
||||
query := &m.GetAlertNotificationsWithUidToSendQuery{OrgId: orgId, Uids: notificationUids}
|
||||
|
||||
if err := bus.Dispatch(query); err != nil {
|
||||
return nil, err
|
||||
@ -168,7 +168,7 @@ func (n *notificationService) getNeededNotifiers(orgId int64, notificationIds []
|
||||
for _, notification := range query.Result {
|
||||
not, err := InitNotifier(notification)
|
||||
if err != nil {
|
||||
n.log.Error("Could not create notifier", "notifier", notification.Id, "error", err)
|
||||
n.log.Error("Could not create notifier", "notifier", notification.Uid, "error", err)
|
||||
continue
|
||||
}
|
||||
|
||||
|
@ -16,7 +16,7 @@ const (
|
||||
type NotifierBase struct {
|
||||
Name string
|
||||
Type string
|
||||
Id int64
|
||||
Uid string
|
||||
IsDeault bool
|
||||
UploadImage bool
|
||||
SendReminder bool
|
||||
@ -34,7 +34,7 @@ func NewNotifierBase(model *models.AlertNotification) NotifierBase {
|
||||
}
|
||||
|
||||
return NotifierBase{
|
||||
Id: model.Id,
|
||||
Uid: model.Uid,
|
||||
Name: model.Name,
|
||||
IsDeault: model.IsDefault,
|
||||
Type: model.Type,
|
||||
@ -110,8 +110,8 @@ func (n *NotifierBase) NeedsImage() bool {
|
||||
return n.UploadImage
|
||||
}
|
||||
|
||||
func (n *NotifierBase) GetNotifierId() int64 {
|
||||
return n.Id
|
||||
func (n *NotifierBase) GetNotifierUid() string {
|
||||
return n.Uid
|
||||
}
|
||||
|
||||
func (n *NotifierBase) GetIsDefault() bool {
|
||||
|
@ -173,7 +173,7 @@ func TestBaseNotifier(t *testing.T) {
|
||||
bJson := simplejson.New()
|
||||
|
||||
model := &m.AlertNotification{
|
||||
Id: 1,
|
||||
Uid: "1",
|
||||
Name: "name",
|
||||
Type: "email",
|
||||
Settings: bJson,
|
||||
|
@ -30,7 +30,7 @@ type Rule struct {
|
||||
ExecutionErrorState m.ExecutionErrorOption
|
||||
State m.AlertStateType
|
||||
Conditions []Condition
|
||||
Notifications []int64
|
||||
Notifications []string
|
||||
|
||||
StateChanges int64
|
||||
}
|
||||
@ -126,11 +126,15 @@ func NewRuleFromDBAlert(ruleDef *m.Alert) (*Rule, error) {
|
||||
|
||||
for _, v := range ruleDef.Settings.Get("notifications").MustArray() {
|
||||
jsonModel := simplejson.NewFromAny(v)
|
||||
id, err := jsonModel.Get("id").Int64()
|
||||
if err != nil {
|
||||
return nil, ValidationError{Reason: "Invalid notification schema", DashboardId: model.DashboardId, Alertid: model.Id, PanelId: model.PanelId}
|
||||
if id, err := jsonModel.Get("id").Int64(); err == nil {
|
||||
model.Notifications = append(model.Notifications, fmt.Sprintf("%09d", id))
|
||||
} else {
|
||||
if uid, err := jsonModel.Get("uid").String(); err != nil {
|
||||
return nil, ValidationError{Reason: "Neither id nor uid is specified, " + err.Error(), DashboardId: model.DashboardId, Alertid: model.Id, PanelId: model.PanelId}
|
||||
} else {
|
||||
model.Notifications = append(model.Notifications, uid)
|
||||
}
|
||||
}
|
||||
model.Notifications = append(model.Notifications, id)
|
||||
}
|
||||
|
||||
for index, condition := range ruleDef.Settings.Get("conditions").MustArray() {
|
||||
|
@ -5,6 +5,7 @@ import (
|
||||
|
||||
"github.com/grafana/grafana/pkg/components/simplejson"
|
||||
m "github.com/grafana/grafana/pkg/models"
|
||||
"github.com/grafana/grafana/pkg/services/sqlstore"
|
||||
. "github.com/smartystreets/goconvey/convey"
|
||||
)
|
||||
|
||||
@ -45,6 +46,7 @@ func TestAlertRuleFrequencyParsing(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestAlertRuleModel(t *testing.T) {
|
||||
sqlstore.InitTestDB(t)
|
||||
Convey("Testing alert rule", t, func() {
|
||||
|
||||
RegisterCondition("test", func(model *simplejson.Json, index int) (Condition, error) {
|
||||
@ -57,46 +59,57 @@ func TestAlertRuleModel(t *testing.T) {
|
||||
})
|
||||
|
||||
Convey("can construct alert rule model", func() {
|
||||
json := `
|
||||
{
|
||||
"name": "name2",
|
||||
"description": "desc2",
|
||||
"handler": 0,
|
||||
"noDataMode": "critical",
|
||||
"enabled": true,
|
||||
"frequency": "60s",
|
||||
"conditions": [
|
||||
{
|
||||
"type": "test",
|
||||
"prop": 123
|
||||
}
|
||||
],
|
||||
"notifications": [
|
||||
{"id": 1134},
|
||||
{"id": 22}
|
||||
]
|
||||
}
|
||||
`
|
||||
|
||||
alertJSON, jsonErr := simplejson.NewJson([]byte(json))
|
||||
So(jsonErr, ShouldBeNil)
|
||||
|
||||
alert := &m.Alert{
|
||||
Id: 1,
|
||||
OrgId: 1,
|
||||
DashboardId: 1,
|
||||
PanelId: 1,
|
||||
|
||||
Settings: alertJSON,
|
||||
}
|
||||
|
||||
alertRule, err := NewRuleFromDBAlert(alert)
|
||||
firstNotification := m.CreateAlertNotificationCommand{OrgId: 1, Name: "1"}
|
||||
err := sqlstore.CreateAlertNotificationCommand(&firstNotification)
|
||||
So(err, ShouldBeNil)
|
||||
secondNotification := m.CreateAlertNotificationCommand{Uid: "notifier2", OrgId: 1, Name: "2"}
|
||||
err = sqlstore.CreateAlertNotificationCommand(&secondNotification)
|
||||
So(err, ShouldBeNil)
|
||||
|
||||
So(len(alertRule.Conditions), ShouldEqual, 1)
|
||||
Convey("with notification id and uid", func() {
|
||||
json := `
|
||||
{
|
||||
"name": "name2",
|
||||
"description": "desc2",
|
||||
"handler": 0,
|
||||
"noDataMode": "critical",
|
||||
"enabled": true,
|
||||
"frequency": "60s",
|
||||
"conditions": [
|
||||
{
|
||||
"type": "test",
|
||||
"prop": 123
|
||||
}
|
||||
],
|
||||
"notifications": [
|
||||
{"id": 1},
|
||||
{"uid": "notifier2"}
|
||||
]
|
||||
}
|
||||
`
|
||||
|
||||
Convey("Can read notifications", func() {
|
||||
So(len(alertRule.Notifications), ShouldEqual, 2)
|
||||
alertJSON, jsonErr := simplejson.NewJson([]byte(json))
|
||||
So(jsonErr, ShouldBeNil)
|
||||
|
||||
alert := &m.Alert{
|
||||
Id: 1,
|
||||
OrgId: 1,
|
||||
DashboardId: 1,
|
||||
PanelId: 1,
|
||||
|
||||
Settings: alertJSON,
|
||||
}
|
||||
|
||||
alertRule, err := NewRuleFromDBAlert(alert)
|
||||
So(err, ShouldBeNil)
|
||||
|
||||
So(len(alertRule.Conditions), ShouldEqual, 1)
|
||||
|
||||
Convey("Can read notifications", func() {
|
||||
So(len(alertRule.Notifications), ShouldEqual, 2)
|
||||
So(alertRule.Notifications, ShouldContain, "000000001")
|
||||
So(alertRule.Notifications, ShouldContain, "notifier2")
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -108,8 +121,8 @@ func TestAlertRuleModel(t *testing.T) {
|
||||
"noDataMode": "critical",
|
||||
"enabled": true,
|
||||
"frequency": "0s",
|
||||
"conditions": [ { "type": "test", "prop": 123 } ],
|
||||
"notifications": []
|
||||
"conditions": [ { "type": "test", "prop": 123 } ],
|
||||
"notifications": []
|
||||
}`
|
||||
|
||||
alertJSON, jsonErr := simplejson.NewJson([]byte(json))
|
||||
@ -129,5 +142,43 @@ func TestAlertRuleModel(t *testing.T) {
|
||||
So(err, ShouldBeNil)
|
||||
So(alertRule.Frequency, ShouldEqual, 60)
|
||||
})
|
||||
|
||||
Convey("raise error in case of missing notification id and uid", func() {
|
||||
json := `
|
||||
{
|
||||
"name": "name2",
|
||||
"description": "desc2",
|
||||
"noDataMode": "critical",
|
||||
"enabled": true,
|
||||
"frequency": "60s",
|
||||
"conditions": [
|
||||
{
|
||||
"type": "test",
|
||||
"prop": 123
|
||||
}
|
||||
],
|
||||
"notifications": [
|
||||
{"not_id_uid": "1134"}
|
||||
]
|
||||
}
|
||||
`
|
||||
|
||||
alertJSON, jsonErr := simplejson.NewJson([]byte(json))
|
||||
So(jsonErr, ShouldBeNil)
|
||||
|
||||
alert := &m.Alert{
|
||||
Id: 1,
|
||||
OrgId: 1,
|
||||
DashboardId: 1,
|
||||
PanelId: 1,
|
||||
Frequency: 0,
|
||||
|
||||
Settings: alertJSON,
|
||||
}
|
||||
|
||||
_, err := NewRuleFromDBAlert(alert)
|
||||
So(err, ShouldNotBeNil)
|
||||
So(err.Error(), ShouldEqual, "Alert validation error: Neither id nor uid is specified, type assertion to string failed AlertId: 1 PanelId: 1 DashboardId: 1")
|
||||
})
|
||||
})
|
||||
}
|
||||
|
@ -44,7 +44,10 @@
|
||||
"noDataState": "no_data",
|
||||
"notifications": [
|
||||
{
|
||||
"id": 6
|
||||
"uid": "notifier1"
|
||||
},
|
||||
{
|
||||
"id": 2
|
||||
}
|
||||
]
|
||||
},
|
||||
|
@ -45,7 +45,10 @@
|
||||
"noDataState": "no_data",
|
||||
"notifications": [
|
||||
{
|
||||
"id": 6
|
||||
"id": 1
|
||||
},
|
||||
{
|
||||
"uid": "notifier2"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
180
pkg/services/provisioning/notifiers/alert_notifications.go
Normal file
180
pkg/services/provisioning/notifiers/alert_notifications.go
Normal file
@ -0,0 +1,180 @@
|
||||
package notifiers
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"github.com/grafana/grafana/pkg/bus"
|
||||
"github.com/grafana/grafana/pkg/log"
|
||||
"github.com/grafana/grafana/pkg/models"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrInvalidConfigTooManyDefault = errors.New("Alert notification provisioning config is invalid. Only one alert notification can be marked as default")
|
||||
)
|
||||
|
||||
func Provision(configDirectory string) error {
|
||||
dc := newNotificationProvisioner(log.New("provisioning.notifiers"))
|
||||
return dc.applyChanges(configDirectory)
|
||||
}
|
||||
|
||||
type NotificationProvisioner struct {
|
||||
log log.Logger
|
||||
cfgProvider *configReader
|
||||
}
|
||||
|
||||
func newNotificationProvisioner(log log.Logger) NotificationProvisioner {
|
||||
return NotificationProvisioner{
|
||||
log: log,
|
||||
cfgProvider: &configReader{log: log},
|
||||
}
|
||||
}
|
||||
|
||||
func (dc *NotificationProvisioner) apply(cfg *notificationsAsConfig) error {
|
||||
if err := dc.deleteNotifications(cfg.DeleteNotifications); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := dc.mergeNotifications(cfg.Notifications); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (dc *NotificationProvisioner) deleteNotifications(notificationToDelete []*deleteNotificationConfig) error {
|
||||
for _, notification := range notificationToDelete {
|
||||
dc.log.Info("Deleting alert notification", "name", notification.Name, "uid", notification.Uid)
|
||||
|
||||
if notification.OrgId == 0 && notification.OrgName != "" {
|
||||
getOrg := &models.GetOrgByNameQuery{Name: notification.OrgName}
|
||||
if err := bus.Dispatch(getOrg); err != nil {
|
||||
return err
|
||||
}
|
||||
notification.OrgId = getOrg.Result.Id
|
||||
} else if notification.OrgId < 0 {
|
||||
notification.OrgId = 1
|
||||
}
|
||||
|
||||
getNotification := &models.GetAlertNotificationsWithUidQuery{Uid: notification.Uid, OrgId: notification.OrgId}
|
||||
|
||||
if err := bus.Dispatch(getNotification); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if getNotification.Result != nil {
|
||||
cmd := &models.DeleteAlertNotificationWithUidCommand{Uid: getNotification.Result.Uid, OrgId: getNotification.OrgId}
|
||||
if err := bus.Dispatch(cmd); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (dc *NotificationProvisioner) mergeNotifications(notificationToMerge []*notificationFromConfig) error {
|
||||
for _, notification := range notificationToMerge {
|
||||
|
||||
if notification.OrgId == 0 && notification.OrgName != "" {
|
||||
getOrg := &models.GetOrgByNameQuery{Name: notification.OrgName}
|
||||
if err := bus.Dispatch(getOrg); err != nil {
|
||||
return err
|
||||
}
|
||||
notification.OrgId = getOrg.Result.Id
|
||||
} else if notification.OrgId < 0 {
|
||||
notification.OrgId = 1
|
||||
}
|
||||
|
||||
cmd := &models.GetAlertNotificationsWithUidQuery{OrgId: notification.OrgId, Uid: notification.Uid}
|
||||
err := bus.Dispatch(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if cmd.Result == nil {
|
||||
dc.log.Info("Inserting alert notification from configuration ", "name", notification.Name, "uid", notification.Uid)
|
||||
insertCmd := &models.CreateAlertNotificationCommand{
|
||||
Uid: notification.Uid,
|
||||
Name: notification.Name,
|
||||
Type: notification.Type,
|
||||
IsDefault: notification.IsDefault,
|
||||
Settings: notification.SettingsToJson(),
|
||||
OrgId: notification.OrgId,
|
||||
DisableResolveMessage: notification.DisableResolveMessage,
|
||||
Frequency: notification.Frequency,
|
||||
SendReminder: notification.SendReminder,
|
||||
}
|
||||
|
||||
if err := bus.Dispatch(insertCmd); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
dc.log.Info("Updating alert notification from configuration", "name", notification.Name)
|
||||
updateCmd := &models.UpdateAlertNotificationWithUidCommand{
|
||||
Uid: notification.Uid,
|
||||
Name: notification.Name,
|
||||
Type: notification.Type,
|
||||
IsDefault: notification.IsDefault,
|
||||
Settings: notification.SettingsToJson(),
|
||||
OrgId: notification.OrgId,
|
||||
DisableResolveMessage: notification.DisableResolveMessage,
|
||||
Frequency: notification.Frequency,
|
||||
SendReminder: notification.SendReminder,
|
||||
}
|
||||
|
||||
if err := bus.Dispatch(updateCmd); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (cfg *notificationsAsConfig) mapToNotificationFromConfig() *notificationsAsConfig {
|
||||
r := ¬ificationsAsConfig{}
|
||||
if cfg == nil {
|
||||
return r
|
||||
}
|
||||
|
||||
for _, notification := range cfg.Notifications {
|
||||
r.Notifications = append(r.Notifications, ¬ificationFromConfig{
|
||||
Uid: notification.Uid,
|
||||
OrgId: notification.OrgId,
|
||||
OrgName: notification.OrgName,
|
||||
Name: notification.Name,
|
||||
Type: notification.Type,
|
||||
IsDefault: notification.IsDefault,
|
||||
Settings: notification.Settings,
|
||||
DisableResolveMessage: notification.DisableResolveMessage,
|
||||
Frequency: notification.Frequency,
|
||||
SendReminder: notification.SendReminder,
|
||||
})
|
||||
}
|
||||
|
||||
for _, notification := range cfg.DeleteNotifications {
|
||||
r.DeleteNotifications = append(r.DeleteNotifications, &deleteNotificationConfig{
|
||||
Uid: notification.Uid,
|
||||
OrgId: notification.OrgId,
|
||||
OrgName: notification.OrgName,
|
||||
Name: notification.Name,
|
||||
})
|
||||
}
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
func (dc *NotificationProvisioner) applyChanges(configPath string) error {
|
||||
configs, err := dc.cfgProvider.readConfig(configPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, cfg := range configs {
|
||||
if err := dc.apply(cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
163
pkg/services/provisioning/notifiers/config_reader.go
Normal file
163
pkg/services/provisioning/notifiers/config_reader.go
Normal file
@ -0,0 +1,163 @@
|
||||
package notifiers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/grafana/grafana/pkg/log"
|
||||
m "github.com/grafana/grafana/pkg/models"
|
||||
"github.com/grafana/grafana/pkg/services/alerting"
|
||||
"gopkg.in/yaml.v2"
|
||||
)
|
||||
|
||||
type configReader struct {
|
||||
log log.Logger
|
||||
}
|
||||
|
||||
func (cr *configReader) readConfig(path string) ([]*notificationsAsConfig, error) {
|
||||
var notifications []*notificationsAsConfig
|
||||
cr.log.Debug("Looking for alert notification provisioning files", "path", path)
|
||||
|
||||
files, err := ioutil.ReadDir(path)
|
||||
if err != nil {
|
||||
cr.log.Error("Can't read alert notification provisioning files from directory", "path", path)
|
||||
return notifications, nil
|
||||
}
|
||||
|
||||
for _, file := range files {
|
||||
if strings.HasSuffix(file.Name(), ".yaml") || strings.HasSuffix(file.Name(), ".yml") {
|
||||
cr.log.Debug("Parsing alert notifications provisioning file", "path", path, "file.Name", file.Name())
|
||||
notifs, err := cr.parseNotificationConfig(path, file)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if notifs != nil {
|
||||
notifications = append(notifications, notifs)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
cr.log.Debug("Validating alert notifications")
|
||||
if err = validateRequiredField(notifications); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
checkOrgIdAndOrgName(notifications)
|
||||
|
||||
err = validateNotifications(notifications)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return notifications, nil
|
||||
}
|
||||
|
||||
func (cr *configReader) parseNotificationConfig(path string, file os.FileInfo) (*notificationsAsConfig, error) {
|
||||
filename, _ := filepath.Abs(filepath.Join(path, file.Name()))
|
||||
yamlFile, err := ioutil.ReadFile(filename)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var cfg *notificationsAsConfig
|
||||
err = yaml.Unmarshal(yamlFile, &cfg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return cfg.mapToNotificationFromConfig(), nil
|
||||
}
|
||||
|
||||
func checkOrgIdAndOrgName(notifications []*notificationsAsConfig) {
|
||||
for i := range notifications {
|
||||
for _, notification := range notifications[i].Notifications {
|
||||
if notification.OrgId < 1 {
|
||||
if notification.OrgName == "" {
|
||||
notification.OrgId = 1
|
||||
} else {
|
||||
notification.OrgId = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, notification := range notifications[i].DeleteNotifications {
|
||||
if notification.OrgId < 1 {
|
||||
if notification.OrgName == "" {
|
||||
notification.OrgId = 1
|
||||
} else {
|
||||
notification.OrgId = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func validateRequiredField(notifications []*notificationsAsConfig) error {
|
||||
for i := range notifications {
|
||||
var errStrings []string
|
||||
for index, notification := range notifications[i].Notifications {
|
||||
if notification.Name == "" {
|
||||
errStrings = append(
|
||||
errStrings,
|
||||
fmt.Sprintf("Added alert notification item %d in configuration doesn't contain required field name", index+1),
|
||||
)
|
||||
}
|
||||
|
||||
if notification.Uid == "" {
|
||||
errStrings = append(
|
||||
errStrings,
|
||||
fmt.Sprintf("Added alert notification item %d in configuration doesn't contain required field uid", index+1),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
for index, notification := range notifications[i].DeleteNotifications {
|
||||
if notification.Name == "" {
|
||||
errStrings = append(
|
||||
errStrings,
|
||||
fmt.Sprintf("Deleted alert notification item %d in configuration doesn't contain required field name", index+1),
|
||||
)
|
||||
}
|
||||
|
||||
if notification.Uid == "" {
|
||||
errStrings = append(
|
||||
errStrings,
|
||||
fmt.Sprintf("Deleted alert notification item %d in configuration doesn't contain required field uid", index+1),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if len(errStrings) != 0 {
|
||||
return fmt.Errorf(strings.Join(errStrings, "\n"))
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateNotifications(notifications []*notificationsAsConfig) error {
|
||||
|
||||
for i := range notifications {
|
||||
if notifications[i].Notifications == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, notification := range notifications[i].Notifications {
|
||||
_, err := alerting.InitNotifier(&m.AlertNotification{
|
||||
Name: notification.Name,
|
||||
Settings: notification.SettingsToJson(),
|
||||
Type: notification.Type,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
313
pkg/services/provisioning/notifiers/config_reader_test.go
Normal file
313
pkg/services/provisioning/notifiers/config_reader_test.go
Normal file
@ -0,0 +1,313 @@
|
||||
package notifiers
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/grafana/grafana/pkg/log"
|
||||
m "github.com/grafana/grafana/pkg/models"
|
||||
"github.com/grafana/grafana/pkg/services/alerting"
|
||||
"github.com/grafana/grafana/pkg/services/alerting/notifiers"
|
||||
"github.com/grafana/grafana/pkg/services/sqlstore"
|
||||
. "github.com/smartystreets/goconvey/convey"
|
||||
)
|
||||
|
||||
var (
|
||||
correct_properties = "./testdata/test-configs/correct-properties"
|
||||
incorrect_settings = "./testdata/test-configs/incorrect-settings"
|
||||
no_required_fields = "./testdata/test-configs/no-required-fields"
|
||||
correct_properties_with_orgName = "./testdata/test-configs/correct-properties-with-orgName"
|
||||
brokenYaml = "./testdata/test-configs/broken-yaml"
|
||||
doubleNotificationsConfig = "./testdata/test-configs/double-default"
|
||||
emptyFolder = "./testdata/test-configs/empty_folder"
|
||||
emptyFile = "./testdata/test-configs/empty"
|
||||
twoNotificationsConfig = "./testdata/test-configs/two-notifications"
|
||||
unknownNotifier = "./testdata/test-configs/unknown-notifier"
|
||||
)
|
||||
|
||||
func TestNotificationAsConfig(t *testing.T) {
|
||||
logger := log.New("fake.log")
|
||||
|
||||
Convey("Testing notification as configuration", t, func() {
|
||||
sqlstore.InitTestDB(t)
|
||||
|
||||
alerting.RegisterNotifier(&alerting.NotifierPlugin{
|
||||
Type: "slack",
|
||||
Name: "slack",
|
||||
Factory: notifiers.NewSlackNotifier,
|
||||
})
|
||||
|
||||
alerting.RegisterNotifier(&alerting.NotifierPlugin{
|
||||
Type: "email",
|
||||
Name: "email",
|
||||
Factory: notifiers.NewEmailNotifier,
|
||||
})
|
||||
|
||||
Convey("Can read correct properties", func() {
|
||||
cfgProvifer := &configReader{log: log.New("test logger")}
|
||||
cfg, err := cfgProvifer.readConfig(correct_properties)
|
||||
if err != nil {
|
||||
t.Fatalf("readConfig return an error %v", err)
|
||||
}
|
||||
So(len(cfg), ShouldEqual, 1)
|
||||
|
||||
ntCfg := cfg[0]
|
||||
nts := ntCfg.Notifications
|
||||
So(len(nts), ShouldEqual, 4)
|
||||
|
||||
nt := nts[0]
|
||||
So(nt.Name, ShouldEqual, "default-slack-notification")
|
||||
So(nt.Type, ShouldEqual, "slack")
|
||||
So(nt.OrgId, ShouldEqual, 2)
|
||||
So(nt.Uid, ShouldEqual, "notifier1")
|
||||
So(nt.IsDefault, ShouldBeTrue)
|
||||
So(nt.Settings, ShouldResemble, map[string]interface{}{
|
||||
"recipient": "XXX", "token": "xoxb", "uploadImage": true, "url": "https://slack.com",
|
||||
})
|
||||
|
||||
nt = nts[1]
|
||||
So(nt.Name, ShouldEqual, "another-not-default-notification")
|
||||
So(nt.Type, ShouldEqual, "email")
|
||||
So(nt.OrgId, ShouldEqual, 3)
|
||||
So(nt.Uid, ShouldEqual, "notifier2")
|
||||
So(nt.IsDefault, ShouldBeFalse)
|
||||
|
||||
nt = nts[2]
|
||||
So(nt.Name, ShouldEqual, "check-unset-is_default-is-false")
|
||||
So(nt.Type, ShouldEqual, "slack")
|
||||
So(nt.OrgId, ShouldEqual, 3)
|
||||
So(nt.Uid, ShouldEqual, "notifier3")
|
||||
So(nt.IsDefault, ShouldBeFalse)
|
||||
|
||||
nt = nts[3]
|
||||
So(nt.Name, ShouldEqual, "Added notification with whitespaces in name")
|
||||
So(nt.Type, ShouldEqual, "email")
|
||||
So(nt.Uid, ShouldEqual, "notifier4")
|
||||
So(nt.OrgId, ShouldEqual, 3)
|
||||
|
||||
deleteNts := ntCfg.DeleteNotifications
|
||||
So(len(deleteNts), ShouldEqual, 4)
|
||||
|
||||
deleteNt := deleteNts[0]
|
||||
So(deleteNt.Name, ShouldEqual, "default-slack-notification")
|
||||
So(deleteNt.Uid, ShouldEqual, "notifier1")
|
||||
So(deleteNt.OrgId, ShouldEqual, 2)
|
||||
|
||||
deleteNt = deleteNts[1]
|
||||
So(deleteNt.Name, ShouldEqual, "deleted-notification-without-orgId")
|
||||
So(deleteNt.OrgId, ShouldEqual, 1)
|
||||
So(deleteNt.Uid, ShouldEqual, "notifier2")
|
||||
|
||||
deleteNt = deleteNts[2]
|
||||
So(deleteNt.Name, ShouldEqual, "deleted-notification-with-0-orgId")
|
||||
So(deleteNt.OrgId, ShouldEqual, 1)
|
||||
So(deleteNt.Uid, ShouldEqual, "notifier3")
|
||||
|
||||
deleteNt = deleteNts[3]
|
||||
So(deleteNt.Name, ShouldEqual, "Deleted notification with whitespaces in name")
|
||||
So(deleteNt.OrgId, ShouldEqual, 1)
|
||||
So(deleteNt.Uid, ShouldEqual, "notifier4")
|
||||
})
|
||||
|
||||
Convey("One configured notification", func() {
|
||||
Convey("no notification in database", func() {
|
||||
dc := newNotificationProvisioner(logger)
|
||||
err := dc.applyChanges(twoNotificationsConfig)
|
||||
if err != nil {
|
||||
t.Fatalf("applyChanges return an error %v", err)
|
||||
}
|
||||
notificationsQuery := m.GetAllAlertNotificationsQuery{OrgId: 1}
|
||||
err = sqlstore.GetAllAlertNotifications(¬ificationsQuery)
|
||||
So(err, ShouldBeNil)
|
||||
So(notificationsQuery.Result, ShouldNotBeNil)
|
||||
So(len(notificationsQuery.Result), ShouldEqual, 2)
|
||||
})
|
||||
|
||||
Convey("One notification in database with same name and uid", func() {
|
||||
existingNotificationCmd := m.CreateAlertNotificationCommand{
|
||||
Name: "channel1",
|
||||
OrgId: 1,
|
||||
Uid: "notifier1",
|
||||
Type: "slack",
|
||||
}
|
||||
err := sqlstore.CreateAlertNotificationCommand(&existingNotificationCmd)
|
||||
So(err, ShouldBeNil)
|
||||
So(existingNotificationCmd.Result, ShouldNotBeNil)
|
||||
notificationsQuery := m.GetAllAlertNotificationsQuery{OrgId: 1}
|
||||
err = sqlstore.GetAllAlertNotifications(¬ificationsQuery)
|
||||
So(err, ShouldBeNil)
|
||||
So(notificationsQuery.Result, ShouldNotBeNil)
|
||||
So(len(notificationsQuery.Result), ShouldEqual, 1)
|
||||
|
||||
Convey("should update one notification", func() {
|
||||
dc := newNotificationProvisioner(logger)
|
||||
err = dc.applyChanges(twoNotificationsConfig)
|
||||
if err != nil {
|
||||
t.Fatalf("applyChanges return an error %v", err)
|
||||
}
|
||||
err = sqlstore.GetAllAlertNotifications(¬ificationsQuery)
|
||||
So(err, ShouldBeNil)
|
||||
So(notificationsQuery.Result, ShouldNotBeNil)
|
||||
So(len(notificationsQuery.Result), ShouldEqual, 2)
|
||||
|
||||
nts := notificationsQuery.Result
|
||||
nt1 := nts[0]
|
||||
So(nt1.Type, ShouldEqual, "email")
|
||||
So(nt1.Name, ShouldEqual, "channel1")
|
||||
So(nt1.Uid, ShouldEqual, "notifier1")
|
||||
|
||||
nt2 := nts[1]
|
||||
So(nt2.Type, ShouldEqual, "slack")
|
||||
So(nt2.Name, ShouldEqual, "channel2")
|
||||
So(nt2.Uid, ShouldEqual, "notifier2")
|
||||
})
|
||||
})
|
||||
Convey("Two notifications with is_default", func() {
|
||||
dc := newNotificationProvisioner(logger)
|
||||
err := dc.applyChanges(doubleNotificationsConfig)
|
||||
Convey("should both be inserted", func() {
|
||||
So(err, ShouldBeNil)
|
||||
notificationsQuery := m.GetAllAlertNotificationsQuery{OrgId: 1}
|
||||
err = sqlstore.GetAllAlertNotifications(¬ificationsQuery)
|
||||
So(err, ShouldBeNil)
|
||||
So(notificationsQuery.Result, ShouldNotBeNil)
|
||||
So(len(notificationsQuery.Result), ShouldEqual, 2)
|
||||
|
||||
So(notificationsQuery.Result[0].IsDefault, ShouldBeTrue)
|
||||
So(notificationsQuery.Result[1].IsDefault, ShouldBeTrue)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Convey("Two configured notification", func() {
|
||||
Convey("two other notifications in database", func() {
|
||||
existingNotificationCmd := m.CreateAlertNotificationCommand{
|
||||
Name: "channel0",
|
||||
OrgId: 1,
|
||||
Uid: "notifier0",
|
||||
Type: "slack",
|
||||
}
|
||||
err := sqlstore.CreateAlertNotificationCommand(&existingNotificationCmd)
|
||||
So(err, ShouldBeNil)
|
||||
existingNotificationCmd = m.CreateAlertNotificationCommand{
|
||||
Name: "channel3",
|
||||
OrgId: 1,
|
||||
Uid: "notifier3",
|
||||
Type: "slack",
|
||||
}
|
||||
err = sqlstore.CreateAlertNotificationCommand(&existingNotificationCmd)
|
||||
So(err, ShouldBeNil)
|
||||
|
||||
notificationsQuery := m.GetAllAlertNotificationsQuery{OrgId: 1}
|
||||
err = sqlstore.GetAllAlertNotifications(¬ificationsQuery)
|
||||
So(err, ShouldBeNil)
|
||||
So(notificationsQuery.Result, ShouldNotBeNil)
|
||||
So(len(notificationsQuery.Result), ShouldEqual, 2)
|
||||
|
||||
Convey("should have two new notifications", func() {
|
||||
dc := newNotificationProvisioner(logger)
|
||||
err := dc.applyChanges(twoNotificationsConfig)
|
||||
if err != nil {
|
||||
t.Fatalf("applyChanges return an error %v", err)
|
||||
}
|
||||
notificationsQuery = m.GetAllAlertNotificationsQuery{OrgId: 1}
|
||||
err = sqlstore.GetAllAlertNotifications(¬ificationsQuery)
|
||||
So(err, ShouldBeNil)
|
||||
So(notificationsQuery.Result, ShouldNotBeNil)
|
||||
So(len(notificationsQuery.Result), ShouldEqual, 4)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Convey("Can read correct properties with orgName instead of orgId", func() {
|
||||
existingOrg1 := m.CreateOrgCommand{Name: "Main Org. 1"}
|
||||
err := sqlstore.CreateOrg(&existingOrg1)
|
||||
So(err, ShouldBeNil)
|
||||
So(existingOrg1.Result, ShouldNotBeNil)
|
||||
existingOrg2 := m.CreateOrgCommand{Name: "Main Org. 2"}
|
||||
err = sqlstore.CreateOrg(&existingOrg2)
|
||||
So(err, ShouldBeNil)
|
||||
So(existingOrg2.Result, ShouldNotBeNil)
|
||||
|
||||
existingNotificationCmd := m.CreateAlertNotificationCommand{
|
||||
Name: "default-notification-delete",
|
||||
OrgId: existingOrg2.Result.Id,
|
||||
Uid: "notifier2",
|
||||
Type: "slack",
|
||||
}
|
||||
err = sqlstore.CreateAlertNotificationCommand(&existingNotificationCmd)
|
||||
So(err, ShouldBeNil)
|
||||
|
||||
dc := newNotificationProvisioner(logger)
|
||||
err = dc.applyChanges(correct_properties_with_orgName)
|
||||
if err != nil {
|
||||
t.Fatalf("applyChanges return an error %v", err)
|
||||
}
|
||||
|
||||
notificationsQuery := m.GetAllAlertNotificationsQuery{OrgId: existingOrg2.Result.Id}
|
||||
err = sqlstore.GetAllAlertNotifications(¬ificationsQuery)
|
||||
So(err, ShouldBeNil)
|
||||
So(notificationsQuery.Result, ShouldNotBeNil)
|
||||
So(len(notificationsQuery.Result), ShouldEqual, 1)
|
||||
|
||||
nt := notificationsQuery.Result[0]
|
||||
So(nt.Name, ShouldEqual, "default-notification-create")
|
||||
So(nt.OrgId, ShouldEqual, existingOrg2.Result.Id)
|
||||
|
||||
})
|
||||
|
||||
Convey("Config doesn't contain required field", func() {
|
||||
dc := newNotificationProvisioner(logger)
|
||||
err := dc.applyChanges(no_required_fields)
|
||||
So(err, ShouldNotBeNil)
|
||||
|
||||
errString := err.Error()
|
||||
So(errString, ShouldContainSubstring, "Deleted alert notification item 1 in configuration doesn't contain required field uid")
|
||||
So(errString, ShouldContainSubstring, "Deleted alert notification item 2 in configuration doesn't contain required field name")
|
||||
So(errString, ShouldContainSubstring, "Added alert notification item 1 in configuration doesn't contain required field name")
|
||||
So(errString, ShouldContainSubstring, "Added alert notification item 2 in configuration doesn't contain required field uid")
|
||||
})
|
||||
|
||||
Convey("Empty yaml file", func() {
|
||||
Convey("should have not changed repo", func() {
|
||||
dc := newNotificationProvisioner(logger)
|
||||
err := dc.applyChanges(emptyFile)
|
||||
if err != nil {
|
||||
t.Fatalf("applyChanges return an error %v", err)
|
||||
}
|
||||
notificationsQuery := m.GetAllAlertNotificationsQuery{OrgId: 1}
|
||||
err = sqlstore.GetAllAlertNotifications(¬ificationsQuery)
|
||||
So(err, ShouldBeNil)
|
||||
So(notificationsQuery.Result, ShouldBeEmpty)
|
||||
})
|
||||
})
|
||||
|
||||
Convey("Broken yaml should return error", func() {
|
||||
reader := &configReader{log: log.New("test logger")}
|
||||
_, err := reader.readConfig(brokenYaml)
|
||||
So(err, ShouldNotBeNil)
|
||||
})
|
||||
|
||||
Convey("Skip invalid directory", func() {
|
||||
cfgProvifer := &configReader{log: log.New("test logger")}
|
||||
cfg, err := cfgProvifer.readConfig(emptyFolder)
|
||||
if err != nil {
|
||||
t.Fatalf("readConfig return an error %v", err)
|
||||
}
|
||||
So(len(cfg), ShouldEqual, 0)
|
||||
})
|
||||
|
||||
Convey("Unknown notifier should return error", func() {
|
||||
cfgProvifer := &configReader{log: log.New("test logger")}
|
||||
_, err := cfgProvifer.readConfig(unknownNotifier)
|
||||
So(err, ShouldNotBeNil)
|
||||
So(err.Error(), ShouldEqual, "Unsupported notification type")
|
||||
})
|
||||
|
||||
Convey("Read incorrect properties", func() {
|
||||
cfgProvifer := &configReader{log: log.New("test logger")}
|
||||
_, err := cfgProvifer.readConfig(incorrect_settings)
|
||||
So(err, ShouldNotBeNil)
|
||||
So(err.Error(), ShouldEqual, "Alert validation error: Could not find url property in settings")
|
||||
})
|
||||
})
|
||||
}
|
9
pkg/services/provisioning/notifiers/testdata/test-configs/broken-yaml/broken.yaml
vendored
Normal file
9
pkg/services/provisioning/notifiers/testdata/test-configs/broken-yaml/broken.yaml
vendored
Normal file
@ -0,0 +1,9 @@
|
||||
notifiers:
|
||||
- name: notification-channel-1
|
||||
type: slack
|
||||
org_id: 2
|
||||
is_default: true
|
||||
settings:
|
||||
recipient: "XXX"
|
||||
token: "xoxb"
|
||||
uploadImage: true
|
6
pkg/services/provisioning/notifiers/testdata/test-configs/broken-yaml/not.yaml.text
vendored
Normal file
6
pkg/services/provisioning/notifiers/testdata/test-configs/broken-yaml/not.yaml.text
vendored
Normal file
@ -0,0 +1,6 @@
|
||||
#sfxzgnsxzcvnbzcvn
|
||||
cvbn
|
||||
cvbn
|
||||
c
|
||||
vbn
|
||||
cvbncvbn
|
@ -0,0 +1,12 @@
|
||||
notifiers:
|
||||
- name: default-notification-create
|
||||
type: email
|
||||
uid: notifier2
|
||||
settings:
|
||||
addresses: example@example.com
|
||||
org_name: Main Org. 2
|
||||
is_default: false
|
||||
delete_notifiers:
|
||||
- name: default-notification-delete
|
||||
org_name: Main Org. 2
|
||||
uid: notifier2
|
@ -0,0 +1,42 @@
|
||||
notifiers:
|
||||
- name: default-slack-notification
|
||||
type: slack
|
||||
uid: notifier1
|
||||
org_id: 2
|
||||
uid: "notifier1"
|
||||
is_default: true
|
||||
settings:
|
||||
recipient: "XXX"
|
||||
token: "xoxb"
|
||||
uploadImage: true
|
||||
url: https://slack.com
|
||||
- name: another-not-default-notification
|
||||
type: email
|
||||
settings:
|
||||
addresses: example@exmaple.com
|
||||
org_id: 3
|
||||
uid: "notifier2"
|
||||
is_default: false
|
||||
- name: check-unset-is_default-is-false
|
||||
type: slack
|
||||
org_id: 3
|
||||
uid: "notifier3"
|
||||
settings:
|
||||
url: https://slack.com
|
||||
- name: Added notification with whitespaces in name
|
||||
type: email
|
||||
org_id: 3
|
||||
uid: "notifier4"
|
||||
settings:
|
||||
addresses: example@exmaple.com
|
||||
delete_notifiers:
|
||||
- name: default-slack-notification
|
||||
org_id: 2
|
||||
uid: notifier1
|
||||
- name: deleted-notification-without-orgId
|
||||
uid: "notifier2"
|
||||
- name: deleted-notification-with-0-orgId
|
||||
org_id: 0
|
||||
uid: "notifier3"
|
||||
- name: Deleted notification with whitespaces in name
|
||||
uid: "notifier4"
|
7
pkg/services/provisioning/notifiers/testdata/test-configs/double-default/default-1.yml
vendored
Normal file
7
pkg/services/provisioning/notifiers/testdata/test-configs/double-default/default-1.yml
vendored
Normal file
@ -0,0 +1,7 @@
|
||||
notifiers:
|
||||
- name: first-default
|
||||
type: slack
|
||||
uid: notifier1
|
||||
is_default: true
|
||||
settings:
|
||||
url: https://slack.com
|
7
pkg/services/provisioning/notifiers/testdata/test-configs/double-default/default-2.yaml
vendored
Normal file
7
pkg/services/provisioning/notifiers/testdata/test-configs/double-default/default-2.yaml
vendored
Normal file
@ -0,0 +1,7 @@
|
||||
notifiers:
|
||||
- name: second-default
|
||||
type: email
|
||||
uid: notifier2
|
||||
is_default: true
|
||||
settings:
|
||||
addresses: example@example.com
|
0
pkg/services/provisioning/notifiers/testdata/test-configs/empty/empty.yaml
vendored
Normal file
0
pkg/services/provisioning/notifiers/testdata/test-configs/empty/empty.yaml
vendored
Normal file
4
pkg/services/provisioning/notifiers/testdata/test-configs/empty_folder/.gitignore
vendored
Normal file
4
pkg/services/provisioning/notifiers/testdata/test-configs/empty_folder/.gitignore
vendored
Normal file
@ -0,0 +1,4 @@
|
||||
# Ignore everything in this directory
|
||||
*
|
||||
# Except this file
|
||||
!.gitignore
|
@ -0,0 +1,10 @@
|
||||
notifiers:
|
||||
- name: slack-notification-without-url-in-settings
|
||||
type: slack
|
||||
org_id: 2
|
||||
uid: notifier1
|
||||
is_default: true
|
||||
settings:
|
||||
recipient: "XXX"
|
||||
token: "xoxb"
|
||||
uploadImage: true
|
@ -0,0 +1,35 @@
|
||||
notifiers:
|
||||
- type: slack
|
||||
org_id: 2
|
||||
uid: no-name_added-notification
|
||||
is_default: true
|
||||
settings:
|
||||
recipient: "XXX"
|
||||
token: "xoxb"
|
||||
uploadImage: true
|
||||
- name: no-uid
|
||||
type: slack
|
||||
org_id: 2
|
||||
is_default: true
|
||||
settings:
|
||||
recipient: "XXX"
|
||||
token: "xoxb"
|
||||
uploadImage: true
|
||||
delete_notifiers:
|
||||
- name: no-uid
|
||||
type: slack
|
||||
org_id: 2
|
||||
is_default: true
|
||||
settings:
|
||||
recipient: "XXX"
|
||||
token: "xoxb"
|
||||
uploadImage: true
|
||||
- type: slack
|
||||
org_id: 2
|
||||
uid: no-name_added-notification
|
||||
is_default: true
|
||||
settings:
|
||||
recipient: "XXX"
|
||||
token: "xoxb"
|
||||
uploadImage: true
|
||||
|
@ -0,0 +1,12 @@
|
||||
notifiers:
|
||||
- name: channel1
|
||||
type: email
|
||||
uid: notifier1
|
||||
org_id: 1
|
||||
settings:
|
||||
addresses: example@example.com
|
||||
- name: channel2
|
||||
type: slack
|
||||
uid: notifier2
|
||||
settings:
|
||||
url: http://slack.com
|
4
pkg/services/provisioning/notifiers/testdata/test-configs/unknown-notifier/notification.yaml
vendored
Normal file
4
pkg/services/provisioning/notifiers/testdata/test-configs/unknown-notifier/notification.yaml
vendored
Normal file
@ -0,0 +1,4 @@
|
||||
notifiers:
|
||||
- name: unknown-notifier
|
||||
type: nonexisting
|
||||
uid: notifier1
|
38
pkg/services/provisioning/notifiers/types.go
Normal file
38
pkg/services/provisioning/notifiers/types.go
Normal file
@ -0,0 +1,38 @@
|
||||
package notifiers
|
||||
|
||||
import "github.com/grafana/grafana/pkg/components/simplejson"
|
||||
|
||||
type notificationsAsConfig struct {
|
||||
Notifications []*notificationFromConfig `json:"notifiers" yaml:"notifiers"`
|
||||
DeleteNotifications []*deleteNotificationConfig `json:"delete_notifiers" yaml:"delete_notifiers"`
|
||||
}
|
||||
|
||||
type deleteNotificationConfig struct {
|
||||
Uid string `json:"uid" yaml:"uid"`
|
||||
Name string `json:"name" yaml:"name"`
|
||||
OrgId int64 `json:"org_id" yaml:"org_id"`
|
||||
OrgName string `json:"org_name" yaml:"org_name"`
|
||||
}
|
||||
|
||||
type notificationFromConfig struct {
|
||||
Uid string `json:"uid" yaml:"uid"`
|
||||
OrgId int64 `json:"org_id" yaml:"org_id"`
|
||||
OrgName string `json:"org_name" yaml:"org_name"`
|
||||
Name string `json:"name" yaml:"name"`
|
||||
Type string `json:"type" yaml:"type"`
|
||||
SendReminder bool `json:"send_reminder" yaml:"send_reminder"`
|
||||
DisableResolveMessage bool `json:"disable_resolve_message" yaml:"disable_resolve_message"`
|
||||
Frequency string `json:"frequency" yaml:"frequency"`
|
||||
IsDefault bool `json:"is_default" yaml:"is_default"`
|
||||
Settings map[string]interface{} `json:"settings" yaml:"settings"`
|
||||
}
|
||||
|
||||
func (notification notificationFromConfig) SettingsToJson() *simplejson.Json {
|
||||
settings := simplejson.New()
|
||||
if len(notification.Settings) > 0 {
|
||||
for k, v := range notification.Settings {
|
||||
settings.Set(k, v)
|
||||
}
|
||||
}
|
||||
return settings
|
||||
}
|
@ -8,6 +8,7 @@ import (
|
||||
"github.com/grafana/grafana/pkg/registry"
|
||||
"github.com/grafana/grafana/pkg/services/provisioning/dashboards"
|
||||
"github.com/grafana/grafana/pkg/services/provisioning/datasources"
|
||||
"github.com/grafana/grafana/pkg/services/provisioning/notifiers"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
)
|
||||
|
||||
@ -25,6 +26,11 @@ func (ps *ProvisioningService) Init() error {
|
||||
return fmt.Errorf("Datasource provisioning error: %v", err)
|
||||
}
|
||||
|
||||
alertNotificationsPath := path.Join(ps.Cfg.ProvisioningPath, "notifiers")
|
||||
if err := notifiers.Provision(alertNotificationsPath); err != nil {
|
||||
return fmt.Errorf("Alert notification provisioning error: %v", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
@ -10,6 +10,7 @@ import (
|
||||
|
||||
"github.com/grafana/grafana/pkg/bus"
|
||||
m "github.com/grafana/grafana/pkg/models"
|
||||
"github.com/grafana/grafana/pkg/util"
|
||||
)
|
||||
|
||||
func init() {
|
||||
@ -17,11 +18,15 @@ func init() {
|
||||
bus.AddHandler("sql", CreateAlertNotificationCommand)
|
||||
bus.AddHandler("sql", UpdateAlertNotification)
|
||||
bus.AddHandler("sql", DeleteAlertNotification)
|
||||
bus.AddHandler("sql", GetAlertNotificationsToSend)
|
||||
bus.AddHandler("sql", GetAllAlertNotifications)
|
||||
bus.AddHandlerCtx("sql", GetOrCreateAlertNotificationState)
|
||||
bus.AddHandlerCtx("sql", SetAlertNotificationStateToCompleteCommand)
|
||||
bus.AddHandlerCtx("sql", SetAlertNotificationStateToPendingCommand)
|
||||
|
||||
bus.AddHandler("sql", GetAlertNotificationsWithUid)
|
||||
bus.AddHandler("sql", UpdateAlertNotificationWithUid)
|
||||
bus.AddHandler("sql", DeleteAlertNotificationWithUid)
|
||||
bus.AddHandler("sql", GetAlertNotificationsWithUidToSend)
|
||||
}
|
||||
|
||||
func DeleteAlertNotification(cmd *m.DeleteAlertNotificationCommand) error {
|
||||
@ -39,10 +44,33 @@ func DeleteAlertNotification(cmd *m.DeleteAlertNotificationCommand) error {
|
||||
})
|
||||
}
|
||||
|
||||
func DeleteAlertNotificationWithUid(cmd *m.DeleteAlertNotificationWithUidCommand) error {
|
||||
existingNotification := &m.GetAlertNotificationsWithUidQuery{OrgId: cmd.OrgId, Uid: cmd.Uid}
|
||||
if err := getAlertNotificationWithUidInternal(existingNotification, newSession()); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if existingNotification.Result != nil {
|
||||
deleteCommand := &m.DeleteAlertNotificationCommand{
|
||||
Id: existingNotification.Result.Id,
|
||||
OrgId: existingNotification.Result.OrgId,
|
||||
}
|
||||
if err := bus.Dispatch(deleteCommand); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func GetAlertNotifications(query *m.GetAlertNotificationsQuery) error {
|
||||
return getAlertNotificationInternal(query, newSession())
|
||||
}
|
||||
|
||||
func GetAlertNotificationsWithUid(query *m.GetAlertNotificationsWithUidQuery) error {
|
||||
return getAlertNotificationWithUidInternal(query, newSession())
|
||||
}
|
||||
|
||||
func GetAllAlertNotifications(query *m.GetAllAlertNotificationsQuery) error {
|
||||
results := make([]*m.AlertNotification, 0)
|
||||
if err := x.Where("org_id = ?", query.OrgId).Find(&results); err != nil {
|
||||
@ -53,12 +81,13 @@ func GetAllAlertNotifications(query *m.GetAllAlertNotificationsQuery) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func GetAlertNotificationsToSend(query *m.GetAlertNotificationsToSendQuery) error {
|
||||
func GetAlertNotificationsWithUidToSend(query *m.GetAlertNotificationsWithUidToSendQuery) error {
|
||||
var sql bytes.Buffer
|
||||
params := make([]interface{}, 0)
|
||||
|
||||
sql.WriteString(`SELECT
|
||||
sql.WriteString(`SELECT
|
||||
alert_notification.id,
|
||||
alert_notification.uid,
|
||||
alert_notification.org_id,
|
||||
alert_notification.name,
|
||||
alert_notification.type,
|
||||
@ -77,9 +106,10 @@ func GetAlertNotificationsToSend(query *m.GetAlertNotificationsToSendQuery) erro
|
||||
|
||||
sql.WriteString(` AND ((alert_notification.is_default = ?)`)
|
||||
params = append(params, dialect.BooleanStr(true))
|
||||
if len(query.Ids) > 0 {
|
||||
sql.WriteString(` OR alert_notification.id IN (?` + strings.Repeat(",?", len(query.Ids)-1) + ")")
|
||||
for _, v := range query.Ids {
|
||||
|
||||
if len(query.Uids) > 0 {
|
||||
sql.WriteString(` OR alert_notification.uid IN (?` + strings.Repeat(",?", len(query.Uids)-1) + ")")
|
||||
for _, v := range query.Uids {
|
||||
params = append(params, v)
|
||||
}
|
||||
}
|
||||
@ -142,16 +172,70 @@ func getAlertNotificationInternal(query *m.GetAlertNotificationsQuery, sess *DBS
|
||||
return nil
|
||||
}
|
||||
|
||||
func getAlertNotificationWithUidInternal(query *m.GetAlertNotificationsWithUidQuery, sess *DBSession) error {
|
||||
var sql bytes.Buffer
|
||||
params := make([]interface{}, 0)
|
||||
|
||||
sql.WriteString(`SELECT
|
||||
alert_notification.id,
|
||||
alert_notification.uid,
|
||||
alert_notification.org_id,
|
||||
alert_notification.name,
|
||||
alert_notification.type,
|
||||
alert_notification.created,
|
||||
alert_notification.updated,
|
||||
alert_notification.settings,
|
||||
alert_notification.is_default,
|
||||
alert_notification.disable_resolve_message,
|
||||
alert_notification.send_reminder,
|
||||
alert_notification.frequency
|
||||
FROM alert_notification
|
||||
`)
|
||||
|
||||
sql.WriteString(` WHERE alert_notification.org_id = ? AND alert_notification.uid = ?`)
|
||||
params = append(params, query.OrgId, query.Uid)
|
||||
|
||||
results := make([]*m.AlertNotification, 0)
|
||||
if err := sess.SQL(sql.String(), params...).Find(&results); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(results) == 0 {
|
||||
query.Result = nil
|
||||
} else {
|
||||
query.Result = results[0]
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func CreateAlertNotificationCommand(cmd *m.CreateAlertNotificationCommand) error {
|
||||
return inTransaction(func(sess *DBSession) error {
|
||||
existingQuery := &m.GetAlertNotificationsQuery{OrgId: cmd.OrgId, Name: cmd.Name}
|
||||
err := getAlertNotificationInternal(existingQuery, sess)
|
||||
if cmd.Uid == "" {
|
||||
if uid, uidGenerationErr := generateNewAlertNotificationUid(sess, cmd.OrgId); uidGenerationErr != nil {
|
||||
return uidGenerationErr
|
||||
} else {
|
||||
cmd.Uid = uid
|
||||
}
|
||||
}
|
||||
existingQuery := &m.GetAlertNotificationsWithUidQuery{OrgId: cmd.OrgId, Uid: cmd.Uid}
|
||||
err := getAlertNotificationWithUidInternal(existingQuery, sess)
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if existingQuery.Result != nil {
|
||||
return fmt.Errorf("Alert notification uid %s already exists", cmd.Uid)
|
||||
}
|
||||
|
||||
// check if name exists
|
||||
sameNameQuery := &m.GetAlertNotificationsQuery{OrgId: cmd.OrgId, Name: cmd.Name}
|
||||
if err := getAlertNotificationInternal(sameNameQuery, sess); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if sameNameQuery.Result != nil {
|
||||
return fmt.Errorf("Alert notification name %s already exists", cmd.Name)
|
||||
}
|
||||
|
||||
@ -168,6 +252,7 @@ func CreateAlertNotificationCommand(cmd *m.CreateAlertNotificationCommand) error
|
||||
}
|
||||
|
||||
alertNotification := &m.AlertNotification{
|
||||
Uid: cmd.Uid,
|
||||
OrgId: cmd.OrgId,
|
||||
Name: cmd.Name,
|
||||
Type: cmd.Type,
|
||||
@ -189,6 +274,22 @@ func CreateAlertNotificationCommand(cmd *m.CreateAlertNotificationCommand) error
|
||||
})
|
||||
}
|
||||
|
||||
func generateNewAlertNotificationUid(sess *DBSession, orgId int64) (string, error) {
|
||||
for i := 0; i < 3; i++ {
|
||||
uid := util.GenerateShortUid()
|
||||
exists, err := sess.Where("org_id=? AND uid=?", orgId, uid).Get(&m.AlertNotification{})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if !exists {
|
||||
return uid, nil
|
||||
}
|
||||
}
|
||||
|
||||
return "", m.ErrAlertNotificationFailedGenerateUniqueUid
|
||||
}
|
||||
|
||||
func UpdateAlertNotification(cmd *m.UpdateAlertNotificationCommand) error {
|
||||
return inTransaction(func(sess *DBSession) (err error) {
|
||||
current := m.AlertNotification{}
|
||||
@ -241,6 +342,39 @@ func UpdateAlertNotification(cmd *m.UpdateAlertNotificationCommand) error {
|
||||
})
|
||||
}
|
||||
|
||||
func UpdateAlertNotificationWithUid(cmd *m.UpdateAlertNotificationWithUidCommand) error {
|
||||
getAlertNotificationWithUidQuery := &m.GetAlertNotificationsWithUidQuery{OrgId: cmd.OrgId, Uid: cmd.Uid}
|
||||
|
||||
if err := getAlertNotificationWithUidInternal(getAlertNotificationWithUidQuery, newSession()); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
current := getAlertNotificationWithUidQuery.Result
|
||||
|
||||
if current == nil {
|
||||
return fmt.Errorf("Cannot update, alert notification uid %s doesn't exist", cmd.Uid)
|
||||
}
|
||||
|
||||
updateNotification := &m.UpdateAlertNotificationCommand{
|
||||
Id: current.Id,
|
||||
Name: cmd.Name,
|
||||
Type: cmd.Type,
|
||||
SendReminder: cmd.SendReminder,
|
||||
DisableResolveMessage: cmd.DisableResolveMessage,
|
||||
Frequency: cmd.Frequency,
|
||||
IsDefault: cmd.IsDefault,
|
||||
Settings: cmd.Settings,
|
||||
|
||||
OrgId: cmd.OrgId,
|
||||
}
|
||||
|
||||
if err := bus.Dispatch(updateNotification); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func SetAlertNotificationStateToCompleteCommand(ctx context.Context, cmd *m.SetAlertNotificationStateToCompleteCommand) error {
|
||||
return inTransactionCtx(ctx, func(sess *DBSession) error {
|
||||
version := cmd.Version
|
||||
|
@ -220,11 +220,38 @@ func TestAlertNotificationSQLAccess(t *testing.T) {
|
||||
So(cmd.Result.Type, ShouldEqual, "email")
|
||||
So(cmd.Result.Frequency, ShouldEqual, 10*time.Second)
|
||||
So(cmd.Result.DisableResolveMessage, ShouldBeFalse)
|
||||
So(cmd.Result.Uid, ShouldNotBeEmpty)
|
||||
|
||||
Convey("Cannot save Alert Notification with the same name", func() {
|
||||
err = CreateAlertNotificationCommand(cmd)
|
||||
So(err, ShouldNotBeNil)
|
||||
})
|
||||
Convey("Cannot save Alert Notification with the same name and another uid", func() {
|
||||
anotherUidCmd := &models.CreateAlertNotificationCommand{
|
||||
Name: cmd.Name,
|
||||
Type: cmd.Type,
|
||||
OrgId: 1,
|
||||
SendReminder: cmd.SendReminder,
|
||||
Frequency: cmd.Frequency,
|
||||
Settings: cmd.Settings,
|
||||
Uid: "notifier1",
|
||||
}
|
||||
err = CreateAlertNotificationCommand(anotherUidCmd)
|
||||
So(err, ShouldNotBeNil)
|
||||
})
|
||||
Convey("Can save Alert Notification with another name and another uid", func() {
|
||||
anotherUidCmd := &models.CreateAlertNotificationCommand{
|
||||
Name: "another ops",
|
||||
Type: cmd.Type,
|
||||
OrgId: 1,
|
||||
SendReminder: cmd.SendReminder,
|
||||
Frequency: cmd.Frequency,
|
||||
Settings: cmd.Settings,
|
||||
Uid: "notifier2",
|
||||
}
|
||||
err = CreateAlertNotificationCommand(anotherUidCmd)
|
||||
So(err, ShouldBeNil)
|
||||
})
|
||||
|
||||
Convey("Can update alert notification", func() {
|
||||
newCmd := &models.UpdateAlertNotificationCommand{
|
||||
@ -274,12 +301,12 @@ func TestAlertNotificationSQLAccess(t *testing.T) {
|
||||
So(CreateAlertNotificationCommand(&otherOrg), ShouldBeNil)
|
||||
|
||||
Convey("search", func() {
|
||||
query := &models.GetAlertNotificationsToSendQuery{
|
||||
Ids: []int64{cmd1.Result.Id, cmd2.Result.Id, 112341231},
|
||||
query := &models.GetAlertNotificationsWithUidToSendQuery{
|
||||
Uids: []string{cmd1.Result.Uid, cmd2.Result.Uid, "112341231"},
|
||||
OrgId: 1,
|
||||
}
|
||||
|
||||
err := GetAlertNotificationsToSend(query)
|
||||
err := GetAlertNotificationsWithUidToSend(query)
|
||||
So(err, ShouldBeNil)
|
||||
So(len(query.Result), ShouldEqual, 3)
|
||||
})
|
||||
|
@ -137,4 +137,21 @@ func addAlertMigrations(mg *Migrator) {
|
||||
mg.AddMigration("Add for to alert table", NewAddColumnMigration(alertV1, &Column{
|
||||
Name: "for", Type: DB_BigInt, Nullable: true,
|
||||
}))
|
||||
|
||||
mg.AddMigration("Add column uid in alert_notification", NewAddColumnMigration(alert_notification, &Column{
|
||||
Name: "uid", Type: DB_NVarchar, Length: 40, Nullable: true,
|
||||
}))
|
||||
|
||||
mg.AddMigration("Update uid column values in alert_notification", new(RawSqlMigration).
|
||||
Sqlite("UPDATE alert_notification SET uid=printf('%09d',id) WHERE uid IS NULL;").
|
||||
Postgres("UPDATE alert_notification SET uid=lpad('' || id,9,'0') WHERE uid IS NULL;").
|
||||
Mysql("UPDATE alert_notification SET uid=lpad(id,9,'0') WHERE uid IS NULL;"))
|
||||
|
||||
mg.AddMigration("Add unique index alert_notification_org_id_uid", NewAddIndexMigration(alert_notification, &Index{
|
||||
Cols: []string{"org_id", "uid"}, Type: UniqueIndex,
|
||||
}))
|
||||
|
||||
mg.AddMigration("Remove unique index org_id_name", NewDropIndexMigration(alert_notification, &Index{
|
||||
Cols: []string{"org_id", "name"}, Type: UniqueIndex,
|
||||
}))
|
||||
}
|
||||
|
@ -96,6 +96,15 @@ func (m *msSqlMacroEngine) evaluateMacro(name string, args []string) (string, er
|
||||
return "", fmt.Errorf("missing time column argument for macro %v", name)
|
||||
}
|
||||
return fmt.Sprintf("%s >= %d AND %s <= %d", args[0], m.timeRange.GetFromAsSecondsEpoch(), args[0], m.timeRange.GetToAsSecondsEpoch()), nil
|
||||
case "__unixEpochNanoFilter":
|
||||
if len(args) == 0 {
|
||||
return "", fmt.Errorf("missing time column argument for macro %v", name)
|
||||
}
|
||||
return fmt.Sprintf("%s >= %d AND %s <= %d", args[0], m.timeRange.GetFromAsTimeUTC().UnixNano(), args[0], m.timeRange.GetToAsTimeUTC().UnixNano()), nil
|
||||
case "__unixEpochNanoFrom":
|
||||
return fmt.Sprintf("%d", m.timeRange.GetFromAsTimeUTC().UnixNano()), nil
|
||||
case "__unixEpochNanoTo":
|
||||
return fmt.Sprintf("%d", m.timeRange.GetToAsTimeUTC().UnixNano()), nil
|
||||
case "__unixEpochGroup":
|
||||
if len(args) < 2 {
|
||||
return "", fmt.Errorf("macro %v needs time column and interval and optional fill value", name)
|
||||
|
@ -132,6 +132,26 @@ func TestMacroEngine(t *testing.T) {
|
||||
So(sql, ShouldEqual, fmt.Sprintf("select time_column >= %d AND time_column <= %d", from.Unix(), to.Unix()))
|
||||
})
|
||||
|
||||
Convey("interpolate __unixEpochNanoFilter function", func() {
|
||||
sql, err := engine.Interpolate(query, timeRange, "select $__unixEpochNanoFilter(time_column)")
|
||||
So(err, ShouldBeNil)
|
||||
|
||||
So(sql, ShouldEqual, fmt.Sprintf("select time_column >= %d AND time_column <= %d", from.UnixNano(), to.UnixNano()))
|
||||
})
|
||||
Convey("interpolate __unixEpochNanoFrom function", func() {
|
||||
sql, err := engine.Interpolate(query, timeRange, "select $__unixEpochNanoFrom()")
|
||||
So(err, ShouldBeNil)
|
||||
|
||||
So(sql, ShouldEqual, fmt.Sprintf("select %d", from.UnixNano()))
|
||||
})
|
||||
|
||||
Convey("interpolate __unixEpochNanoTo function", func() {
|
||||
sql, err := engine.Interpolate(query, timeRange, "select $__unixEpochNanoTo()")
|
||||
So(err, ShouldBeNil)
|
||||
|
||||
So(sql, ShouldEqual, fmt.Sprintf("select %d", to.UnixNano()))
|
||||
})
|
||||
|
||||
Convey("interpolate __unixEpochGroup function", func() {
|
||||
|
||||
sql, err := engine.Interpolate(query, timeRange, "SELECT $__unixEpochGroup(time_column,'5m')")
|
||||
@ -163,6 +183,13 @@ func TestMacroEngine(t *testing.T) {
|
||||
|
||||
So(sql, ShouldEqual, fmt.Sprintf("select time_column >= %d AND time_column <= %d", from.Unix(), to.Unix()))
|
||||
})
|
||||
|
||||
Convey("interpolate __unixEpochNanoFilter function", func() {
|
||||
sql, err := engine.Interpolate(query, timeRange, "select $__unixEpochNanoFilter(time_column)")
|
||||
So(err, ShouldBeNil)
|
||||
|
||||
So(sql, ShouldEqual, fmt.Sprintf("select time_column >= %d AND time_column <= %d", from.UnixNano(), to.UnixNano()))
|
||||
})
|
||||
})
|
||||
|
||||
Convey("Given a time range between 1960-02-01 07:00 and 1980-02-03 08:00", func() {
|
||||
@ -183,6 +210,13 @@ func TestMacroEngine(t *testing.T) {
|
||||
|
||||
So(sql, ShouldEqual, fmt.Sprintf("select time_column >= %d AND time_column <= %d", from.Unix(), to.Unix()))
|
||||
})
|
||||
|
||||
Convey("interpolate __unixEpochNanoFilter function", func() {
|
||||
sql, err := engine.Interpolate(query, timeRange, "select $__unixEpochNanoFilter(time_column)")
|
||||
So(err, ShouldBeNil)
|
||||
|
||||
So(sql, ShouldEqual, fmt.Sprintf("select time_column >= %d AND time_column <= %d", from.UnixNano(), to.UnixNano()))
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
@ -91,6 +91,15 @@ func (m *mySqlMacroEngine) evaluateMacro(name string, args []string) (string, er
|
||||
return "", fmt.Errorf("missing time column argument for macro %v", name)
|
||||
}
|
||||
return fmt.Sprintf("%s >= %d AND %s <= %d", args[0], m.timeRange.GetFromAsSecondsEpoch(), args[0], m.timeRange.GetToAsSecondsEpoch()), nil
|
||||
case "__unixEpochNanoFilter":
|
||||
if len(args) == 0 {
|
||||
return "", fmt.Errorf("missing time column argument for macro %v", name)
|
||||
}
|
||||
return fmt.Sprintf("%s >= %d AND %s <= %d", args[0], m.timeRange.GetFromAsTimeUTC().UnixNano(), args[0], m.timeRange.GetToAsTimeUTC().UnixNano()), nil
|
||||
case "__unixEpochNanoFrom":
|
||||
return fmt.Sprintf("%d", m.timeRange.GetFromAsTimeUTC().UnixNano()), nil
|
||||
case "__unixEpochNanoTo":
|
||||
return fmt.Sprintf("%d", m.timeRange.GetToAsTimeUTC().UnixNano()), nil
|
||||
case "__unixEpochGroup":
|
||||
if len(args) < 2 {
|
||||
return "", fmt.Errorf("macro %v needs time column and interval and optional fill value", name)
|
||||
|
@ -84,6 +84,27 @@ func TestMacroEngine(t *testing.T) {
|
||||
So(sql, ShouldEqual, fmt.Sprintf("select time >= %d AND time <= %d", from.Unix(), to.Unix()))
|
||||
})
|
||||
|
||||
Convey("interpolate __unixEpochNanoFilter function", func() {
|
||||
sql, err := engine.Interpolate(query, timeRange, "select $__unixEpochNanoFilter(time)")
|
||||
So(err, ShouldBeNil)
|
||||
|
||||
So(sql, ShouldEqual, fmt.Sprintf("select time >= %d AND time <= %d", from.UnixNano(), to.UnixNano()))
|
||||
})
|
||||
|
||||
Convey("interpolate __unixEpochNanoFrom function", func() {
|
||||
sql, err := engine.Interpolate(query, timeRange, "select $__unixEpochNanoFrom()")
|
||||
So(err, ShouldBeNil)
|
||||
|
||||
So(sql, ShouldEqual, fmt.Sprintf("select %d", from.UnixNano()))
|
||||
})
|
||||
|
||||
Convey("interpolate __unixEpochNanoTo function", func() {
|
||||
sql, err := engine.Interpolate(query, timeRange, "select $__unixEpochNanoTo()")
|
||||
So(err, ShouldBeNil)
|
||||
|
||||
So(sql, ShouldEqual, fmt.Sprintf("select %d", to.UnixNano()))
|
||||
})
|
||||
|
||||
Convey("interpolate __unixEpochGroup function", func() {
|
||||
|
||||
sql, err := engine.Interpolate(query, timeRange, "SELECT $__unixEpochGroup(time_column,'5m')")
|
||||
|
@ -122,6 +122,15 @@ func (m *postgresMacroEngine) evaluateMacro(name string, args []string) (string,
|
||||
return "", fmt.Errorf("missing time column argument for macro %v", name)
|
||||
}
|
||||
return fmt.Sprintf("%s >= %d AND %s <= %d", args[0], m.timeRange.GetFromAsSecondsEpoch(), args[0], m.timeRange.GetToAsSecondsEpoch()), nil
|
||||
case "__unixEpochNanoFilter":
|
||||
if len(args) == 0 {
|
||||
return "", fmt.Errorf("missing time column argument for macro %v", name)
|
||||
}
|
||||
return fmt.Sprintf("%s >= %d AND %s <= %d", args[0], m.timeRange.GetFromAsTimeUTC().UnixNano(), args[0], m.timeRange.GetToAsTimeUTC().UnixNano()), nil
|
||||
case "__unixEpochNanoFrom":
|
||||
return fmt.Sprintf("%d", m.timeRange.GetFromAsTimeUTC().UnixNano()), nil
|
||||
case "__unixEpochNanoTo":
|
||||
return fmt.Sprintf("%d", m.timeRange.GetToAsTimeUTC().UnixNano()), nil
|
||||
case "__unixEpochGroup":
|
||||
if len(args) < 2 {
|
||||
return "", fmt.Errorf("macro %v needs time column and interval and optional fill value", name)
|
||||
|
@ -115,6 +115,25 @@ func TestMacroEngine(t *testing.T) {
|
||||
|
||||
So(sql, ShouldEqual, fmt.Sprintf("select time >= %d AND time <= %d", from.Unix(), to.Unix()))
|
||||
})
|
||||
Convey("interpolate __unixEpochNanoFilter function", func() {
|
||||
sql, err := engine.Interpolate(query, timeRange, "select $__unixEpochNanoFilter(time)")
|
||||
So(err, ShouldBeNil)
|
||||
|
||||
So(sql, ShouldEqual, fmt.Sprintf("select time >= %d AND time <= %d", from.UnixNano(), to.UnixNano()))
|
||||
})
|
||||
Convey("interpolate __unixEpochNanoFrom function", func() {
|
||||
sql, err := engine.Interpolate(query, timeRange, "select $__unixEpochNanoFrom()")
|
||||
So(err, ShouldBeNil)
|
||||
|
||||
So(sql, ShouldEqual, fmt.Sprintf("select %d", from.UnixNano()))
|
||||
})
|
||||
|
||||
Convey("interpolate __unixEpochNanoTo function", func() {
|
||||
sql, err := engine.Interpolate(query, timeRange, "select $__unixEpochNanoTo()")
|
||||
So(err, ShouldBeNil)
|
||||
|
||||
So(sql, ShouldEqual, fmt.Sprintf("select %d", to.UnixNano()))
|
||||
})
|
||||
|
||||
Convey("interpolate __unixEpochGroup function", func() {
|
||||
|
||||
@ -147,6 +166,12 @@ func TestMacroEngine(t *testing.T) {
|
||||
|
||||
So(sql, ShouldEqual, fmt.Sprintf("select time >= %d AND time <= %d", from.Unix(), to.Unix()))
|
||||
})
|
||||
Convey("interpolate __unixEpochNanoFilter function", func() {
|
||||
sql, err := engine.Interpolate(query, timeRange, "select $__unixEpochNanoFilter(time)")
|
||||
So(err, ShouldBeNil)
|
||||
|
||||
So(sql, ShouldEqual, fmt.Sprintf("select time >= %d AND time <= %d", from.UnixNano(), to.UnixNano()))
|
||||
})
|
||||
})
|
||||
|
||||
Convey("Given a time range between 1960-02-01 07:00 and 1980-02-03 08:00", func() {
|
||||
@ -167,6 +192,12 @@ func TestMacroEngine(t *testing.T) {
|
||||
|
||||
So(sql, ShouldEqual, fmt.Sprintf("select time >= %d AND time <= %d", from.Unix(), to.Unix()))
|
||||
})
|
||||
Convey("interpolate __unixEpochNanoFilter function", func() {
|
||||
sql, err := engine.Interpolate(query, timeRange, "select $__unixEpochNanoFilter(time)")
|
||||
So(err, ShouldBeNil)
|
||||
|
||||
So(sql, ShouldEqual, fmt.Sprintf("select time >= %d AND time <= %d", from.UnixNano(), to.UnixNano()))
|
||||
})
|
||||
})
|
||||
|
||||
Convey("Given a time range between 1960-02-01 07:00:00.5 and 1980-02-03 08:00:00.5", func() {
|
||||
|
@ -28,6 +28,7 @@ export function registerAngularDirectives() {
|
||||
['onChange', { watchDepth: 'reference', wrapApply: true }],
|
||||
]);
|
||||
react2AngularDirective('seriesColorPickerPopover', SeriesColorPickerPopover, [
|
||||
'color',
|
||||
'series',
|
||||
'onColorChange',
|
||||
'onToggleAxis',
|
||||
|
@ -80,7 +80,7 @@ const Navigation = ({ main }: { main: NavModelItem }) => {
|
||||
};
|
||||
|
||||
export default class PageHeader extends React.Component<Props, any> {
|
||||
constructor(props) {
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
}
|
||||
|
||||
|
@ -4,7 +4,7 @@ interface Props {
|
||||
pageName?: string;
|
||||
}
|
||||
|
||||
const PageLoader: FC<Props> = ({ pageName }) => {
|
||||
const PageLoader: FC<Props> = ({ pageName = '' }) => {
|
||||
const loadingText = `Loading ${pageName}...`;
|
||||
return (
|
||||
<div className="page-loader-wrapper">
|
||||
|
@ -2,7 +2,6 @@ import config from 'app/core/config';
|
||||
import _ from 'lodash';
|
||||
import coreModule from 'app/core/core_module';
|
||||
import store from 'app/core/store';
|
||||
import { ThemeNames, ThemeName } from '@grafana/ui';
|
||||
|
||||
export class User {
|
||||
isGrafanaAdmin: any;
|
||||
@ -64,10 +63,6 @@ export class ContextSrv {
|
||||
hasAccessToExplore() {
|
||||
return (this.isEditor || config.viewersCanEdit) && config.exploreEnabled;
|
||||
}
|
||||
|
||||
getTheme(): ThemeName {
|
||||
return this.user.lightTheme ? ThemeNames.Light : ThemeNames.Dark;
|
||||
}
|
||||
}
|
||||
|
||||
const contextSrv = new ContextSrv();
|
||||
|
@ -356,7 +356,7 @@ export default class TimeSeries {
|
||||
return false;
|
||||
}
|
||||
|
||||
setColor(color) {
|
||||
setColor(color: string) {
|
||||
this.color = color;
|
||||
this.bars.fillColor = color;
|
||||
}
|
||||
|
22
public/app/core/utils/CancelablePromise.ts
Normal file
22
public/app/core/utils/CancelablePromise.ts
Normal file
@ -0,0 +1,22 @@
|
||||
// https://github.com/facebook/react/issues/5465
|
||||
|
||||
export interface CancelablePromise<T> {
|
||||
promise: Promise<T>;
|
||||
cancel: () => void;
|
||||
}
|
||||
|
||||
export const makePromiseCancelable = <T>(promise: Promise<T>): CancelablePromise<T> => {
|
||||
let hasCanceled_ = false;
|
||||
|
||||
const wrappedPromise = new Promise<T>((resolve, reject) => {
|
||||
promise.then(val => (hasCanceled_ ? reject({ isCanceled: true }) : resolve(val)));
|
||||
promise.catch(error => (hasCanceled_ ? reject({ isCanceled: true }) : reject(error)));
|
||||
});
|
||||
|
||||
return {
|
||||
promise: wrappedPromise,
|
||||
cancel() {
|
||||
hasCanceled_ = true;
|
||||
},
|
||||
};
|
||||
};
|
28
public/app/core/utils/ConfigProvider.tsx
Normal file
28
public/app/core/utils/ConfigProvider.tsx
Normal file
@ -0,0 +1,28 @@
|
||||
import React from 'react';
|
||||
import config, { Settings } from 'app/core/config';
|
||||
import { GrafanaTheme } from '@grafana/ui';
|
||||
|
||||
export const ConfigContext = React.createContext<Settings>(config);
|
||||
export const ConfigConsumer = ConfigContext.Consumer;
|
||||
|
||||
export const provideConfig = (component: React.ComponentType<any>) => {
|
||||
const ConfigProvider = (props: any) => (
|
||||
<ConfigContext.Provider value={config}>{React.createElement(component, { ...props })}</ConfigContext.Provider>
|
||||
);
|
||||
|
||||
return ConfigProvider;
|
||||
};
|
||||
|
||||
interface ThemeProviderProps {
|
||||
children: (theme: GrafanaTheme) => JSX.Element;
|
||||
}
|
||||
|
||||
export const ThemeProvider = ({ children }: ThemeProviderProps) => {
|
||||
return (
|
||||
<ConfigConsumer>
|
||||
{({ bootData }) => {
|
||||
return children(bootData.user.lightTheme ? GrafanaTheme.Light : GrafanaTheme.Dark);
|
||||
}}
|
||||
</ConfigConsumer>
|
||||
);
|
||||
};
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user