Commit c4f55fec by Torkel Ödegaard

Merge branch 'master' into solo-panel-rewrite

parents 6a4777ea e54689a9
...@@ -333,6 +333,7 @@ jobs: ...@@ -333,6 +333,7 @@ jobs:
docker: docker:
- image: grafana/grafana-ci-deploy:1.2.0 - image: grafana/grafana-ci-deploy:1.2.0
steps: steps:
- checkout
- attach_workspace: - attach_workspace:
at: . at: .
- run: - run:
......
# 6.0.0-beta2 (unreleased) # 6.0.0-beta2 (unreleased)
### Minor
* **Pushover**: Adds support for images in pushover notifier [#10780](https://github.com/grafana/grafana/issues/10780), thx [@jpenalbae](https://github.com/jpenalbae)
# 6.0.0-beta1 (2019-01-30) # 6.0.0-beta1 (2019-01-30)
### New Features ### New Features
......
...@@ -2,7 +2,7 @@ import React from 'react'; ...@@ -2,7 +2,7 @@ import React from 'react';
import { shallow } from 'enzyme'; import { shallow } from 'enzyme';
import { Gauge, Props } from './Gauge'; import { Gauge, Props } from './Gauge';
import { TimeSeriesVMs } from '../../types/series'; import { TimeSeriesVMs } from '../../types/data';
import { ValueMapping, MappingType } from '../../types'; import { ValueMapping, MappingType } from '../../types';
jest.mock('jquery', () => ({ jest.mock('jquery', () => ({
......
...@@ -52,3 +52,20 @@ export interface TimeSeriesVMs { ...@@ -52,3 +52,20 @@ export interface TimeSeriesVMs {
[index: number]: TimeSeriesVM; [index: number]: TimeSeriesVM;
length: number; length: number;
} }
interface Column {
text: string;
title?: string;
type?: string;
sort?: boolean;
desc?: boolean;
filterable?: boolean;
unit?: string;
}
export interface TableData {
columns: Column[];
rows: any[];
type: string;
columnMap: any;
}
import { TimeRange, RawTimeRange } from './time'; import { TimeRange, RawTimeRange } from './time';
import { TimeSeries } from './series';
import { PluginMeta } from './plugin'; import { PluginMeta } from './plugin';
import { TableData, TimeSeries } from './data';
export interface DataQueryResponse { export interface DataQueryResponse {
data: TimeSeries[]; data: TimeSeries[] | [TableData];
} }
export interface DataQuery { export interface DataQuery {
......
export * from './series'; export * from './data';
export * from './time'; export * from './time';
export * from './panel'; export * from './panel';
export * from './plugin'; export * from './plugin';
......
import { TimeSeries, LoadingState } from './series'; import { TimeSeries, LoadingState, TableData } from './data';
import { TimeRange } from './time'; import { TimeRange } from './time';
export type InterpolateFunction = (value: string, format?: string | Function) => string; export type InterpolateFunction = (value: string, format?: string | Function) => string;
...@@ -14,6 +14,11 @@ export interface PanelProps<T = any> { ...@@ -14,6 +14,11 @@ export interface PanelProps<T = any> {
onInterpolate: InterpolateFunction; onInterpolate: InterpolateFunction;
} }
export interface PanelData {
timeSeries?: TimeSeries[];
tableData?: TableData;
}
export interface PanelOptionsProps<T = any> { export interface PanelOptionsProps<T = any> {
options: T; options: T;
onChange: (options: T) => void; onChange: (options: T) => void;
......
package notifiers package notifiers
import ( import (
"bytes"
"fmt" "fmt"
"net/url" "io"
"mime/multipart"
"os"
"strconv" "strconv"
"github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/bus"
...@@ -91,6 +94,7 @@ func NewPushoverNotifier(model *m.AlertNotification) (alerting.Notifier, error) ...@@ -91,6 +94,7 @@ func NewPushoverNotifier(model *m.AlertNotification) (alerting.Notifier, error)
retry, _ := strconv.Atoi(model.Settings.Get("retry").MustString()) retry, _ := strconv.Atoi(model.Settings.Get("retry").MustString())
expire, _ := strconv.Atoi(model.Settings.Get("expire").MustString()) expire, _ := strconv.Atoi(model.Settings.Get("expire").MustString())
sound := model.Settings.Get("sound").MustString() sound := model.Settings.Get("sound").MustString()
uploadImage := model.Settings.Get("uploadImage").MustBool(true)
if userKey == "" { if userKey == "" {
return nil, alerting.ValidationError{Reason: "User key not given"} return nil, alerting.ValidationError{Reason: "User key not given"}
...@@ -107,6 +111,7 @@ func NewPushoverNotifier(model *m.AlertNotification) (alerting.Notifier, error) ...@@ -107,6 +111,7 @@ func NewPushoverNotifier(model *m.AlertNotification) (alerting.Notifier, error)
Expire: expire, Expire: expire,
Device: device, Device: device,
Sound: sound, Sound: sound,
Upload: uploadImage,
log: log.New("alerting.notifier.pushover"), log: log.New("alerting.notifier.pushover"),
}, nil }, nil
} }
...@@ -120,6 +125,7 @@ type PushoverNotifier struct { ...@@ -120,6 +125,7 @@ type PushoverNotifier struct {
Expire int Expire int
Device string Device string
Sound string Sound string
Upload bool
log log.Logger log log.Logger
} }
...@@ -140,38 +146,22 @@ func (this *PushoverNotifier) Notify(evalContext *alerting.EvalContext) error { ...@@ -140,38 +146,22 @@ func (this *PushoverNotifier) Notify(evalContext *alerting.EvalContext) error {
if evalContext.Error != nil { if evalContext.Error != nil {
message += fmt.Sprintf("\n<b>Error message:</b> %s", evalContext.Error.Error()) message += fmt.Sprintf("\n<b>Error message:</b> %s", evalContext.Error.Error())
} }
if evalContext.ImagePublicUrl != "" {
message += fmt.Sprintf("\n<a href=\"%s\">Show graph image</a>", evalContext.ImagePublicUrl)
}
if message == "" { if message == "" {
message = "Notification message missing (Set a notification message to replace this text.)" message = "Notification message missing (Set a notification message to replace this text.)"
} }
q := url.Values{} headers, uploadBody, err := this.genPushoverBody(evalContext, message, ruleUrl)
q.Add("user", this.UserKey) if err != nil {
q.Add("token", this.ApiToken) this.log.Error("Failed to generate body for pushover", "error", err)
q.Add("priority", strconv.Itoa(this.Priority)) return err
if this.Priority == 2 {
q.Add("retry", strconv.Itoa(this.Retry))
q.Add("expire", strconv.Itoa(this.Expire))
}
if this.Device != "" {
q.Add("device", this.Device)
}
if this.Sound != "default" {
q.Add("sound", this.Sound)
} }
q.Add("title", evalContext.GetNotificationTitle())
q.Add("url", ruleUrl)
q.Add("url_title", "Show dashboard with alert")
q.Add("message", message)
q.Add("html", "1")
cmd := &m.SendWebhookSync{ cmd := &m.SendWebhookSync{
Url: PUSHOVER_ENDPOINT, Url: PUSHOVER_ENDPOINT,
HttpMethod: "POST", HttpMethod: "POST",
HttpHeader: map[string]string{"Content-Type": "application/x-www-form-urlencoded"}, HttpHeader: headers,
Body: q.Encode(), Body: uploadBody.String(),
} }
if err := bus.DispatchCtx(evalContext.Ctx, cmd); err != nil { if err := bus.DispatchCtx(evalContext.Ctx, cmd); err != nil {
...@@ -181,3 +171,109 @@ func (this *PushoverNotifier) Notify(evalContext *alerting.EvalContext) error { ...@@ -181,3 +171,109 @@ func (this *PushoverNotifier) Notify(evalContext *alerting.EvalContext) error {
return nil return nil
} }
func (this *PushoverNotifier) genPushoverBody(evalContext *alerting.EvalContext, message string, ruleUrl string) (map[string]string, bytes.Buffer, error) {
var b bytes.Buffer
var err error
w := multipart.NewWriter(&b)
// Add image only if requested and available
if this.Upload && evalContext.ImageOnDiskPath != "" {
f, err := os.Open(evalContext.ImageOnDiskPath)
if err != nil {
return nil, b, err
}
defer f.Close()
fw, err := w.CreateFormFile("attachment", evalContext.ImageOnDiskPath)
if err != nil {
return nil, b, err
}
_, err = io.Copy(fw, f)
if err != nil {
return nil, b, err
}
}
// Add the user token
err = w.WriteField("user", this.UserKey)
if err != nil {
return nil, b, err
}
// Add the api token
err = w.WriteField("token", this.ApiToken)
if err != nil {
return nil, b, err
}
// Add priority
err = w.WriteField("priority", strconv.Itoa(this.Priority))
if err != nil {
return nil, b, err
}
if this.Priority == 2 {
err = w.WriteField("retry", strconv.Itoa(this.Retry))
if err != nil {
return nil, b, err
}
err = w.WriteField("expire", strconv.Itoa(this.Expire))
if err != nil {
return nil, b, err
}
}
// Add device
if this.Device != "" {
err = w.WriteField("device", this.Device)
if err != nil {
return nil, b, err
}
}
// Add sound
if this.Sound != "default" {
err = w.WriteField("sound", this.Sound)
if err != nil {
return nil, b, err
}
}
// Add title
err = w.WriteField("title", evalContext.GetNotificationTitle())
if err != nil {
return nil, b, err
}
// Add URL
err = w.WriteField("url", ruleUrl)
if err != nil {
return nil, b, err
}
// Add URL title
err = w.WriteField("url_title", "Show dashboard with alert")
if err != nil {
return nil, b, err
}
// Add message
err = w.WriteField("message", message)
if err != nil {
return nil, b, err
}
// Mark as html message
err = w.WriteField("html", "1")
if err != nil {
return nil, b, err
}
w.Close()
headers := map[string]string{
"Content-Type": w.FormDataContentType(),
}
return headers, b, nil
}
...@@ -151,7 +151,7 @@ func (s *UserAuthTokenServiceImpl) CreateToken(userId int64, clientIP, userAgent ...@@ -151,7 +151,7 @@ func (s *UserAuthTokenServiceImpl) CreateToken(userId int64, clientIP, userAgent
func (s *UserAuthTokenServiceImpl) LookupToken(unhashedToken string) (*userAuthToken, error) { func (s *UserAuthTokenServiceImpl) LookupToken(unhashedToken string) (*userAuthToken, error) {
hashedToken := hashToken(unhashedToken) hashedToken := hashToken(unhashedToken)
if setting.Env == setting.DEV { if setting.Env == setting.DEV {
s.log.Info("looking up token", "unhashed", unhashedToken, "hashed", hashedToken) s.log.Debug("looking up token", "unhashed", unhashedToken, "hashed", hashedToken)
} }
expireBefore := getTime().Add(time.Duration(-86400*s.Cfg.LoginCookieMaxDays) * time.Second).Unix() expireBefore := getTime().Add(time.Duration(-86400*s.Cfg.LoginCookieMaxDays) * time.Second).Unix()
......
...@@ -92,7 +92,7 @@ func (dc *NotificationProvisioner) mergeNotifications(notificationToMerge []*not ...@@ -92,7 +92,7 @@ func (dc *NotificationProvisioner) mergeNotifications(notificationToMerge []*not
} }
if cmd.Result == nil { if cmd.Result == nil {
dc.log.Info("Inserting alert notification from configuration ", "name", notification.Name, "uid", notification.Uid) dc.log.Debug("inserting alert notification from configuration", "name", notification.Name, "uid", notification.Uid)
insertCmd := &models.CreateAlertNotificationCommand{ insertCmd := &models.CreateAlertNotificationCommand{
Uid: notification.Uid, Uid: notification.Uid,
Name: notification.Name, Name: notification.Name,
...@@ -109,7 +109,7 @@ func (dc *NotificationProvisioner) mergeNotifications(notificationToMerge []*not ...@@ -109,7 +109,7 @@ func (dc *NotificationProvisioner) mergeNotifications(notificationToMerge []*not
return err return err
} }
} else { } else {
dc.log.Info("Updating alert notification from configuration", "name", notification.Name) dc.log.Debug("updating alert notification from configuration", "name", notification.Name)
updateCmd := &models.UpdateAlertNotificationWithUidCommand{ updateCmd := &models.UpdateAlertNotificationWithUidCommand{
Uid: notification.Uid, Uid: notification.Uid,
Name: notification.Name, Name: notification.Name,
......
...@@ -24,7 +24,7 @@ export class RowOptionsCtrl { ...@@ -24,7 +24,7 @@ export class RowOptionsCtrl {
export function rowOptionsDirective() { export function rowOptionsDirective() {
return { return {
restrict: 'E', restrict: 'E',
templateUrl: 'public/app/features/dashboard/partials/row_options.html', templateUrl: 'public/app/features/dashboard/components/RowOptions/template.html',
controller: RowOptionsCtrl, controller: RowOptionsCtrl,
bindToController: true, bindToController: true,
controllerAs: 'ctrl', controllerAs: 'ctrl',
......
...@@ -8,13 +8,21 @@ import { DatasourceSrv, getDatasourceSrv } from 'app/features/plugins/datasource ...@@ -8,13 +8,21 @@ import { DatasourceSrv, getDatasourceSrv } from 'app/features/plugins/datasource
// Utils // Utils
import kbn from 'app/core/utils/kbn'; import kbn from 'app/core/utils/kbn';
// Types // Types
import { DataQueryOptions, DataQueryResponse, LoadingState, TimeRange, TimeSeries } from '@grafana/ui/src/types'; import {
DataQueryOptions,
DataQueryResponse,
LoadingState,
PanelData,
TableData,
TimeRange,
TimeSeries,
} from '@grafana/ui';
const DEFAULT_PLUGIN_ERROR = 'Error in plugin'; const DEFAULT_PLUGIN_ERROR = 'Error in plugin';
interface RenderProps { interface RenderProps {
loading: LoadingState; loading: LoadingState;
timeSeries: TimeSeries[]; panelData: PanelData;
} }
export interface Props { export interface Props {
...@@ -129,6 +137,7 @@ export class DataPanel extends Component<Props, State> { ...@@ -129,6 +137,7 @@ export class DataPanel extends Component<Props, State> {
console.log('Issuing DataPanel query', queryOptions); console.log('Issuing DataPanel query', queryOptions);
const resp = await ds.query(queryOptions); const resp = await ds.query(queryOptions);
console.log('Issuing DataPanel query Resp', resp); console.log('Issuing DataPanel query Resp', resp);
if (this.isUnmounted) { if (this.isUnmounted) {
...@@ -160,11 +169,27 @@ export class DataPanel extends Component<Props, State> { ...@@ -160,11 +169,27 @@ export class DataPanel extends Component<Props, State> {
} }
}; };
getPanelData = () => {
const { response } = this.state;
if (response.data.length > 0 && (response.data[0] as TableData).type === 'table') {
return {
tableData: response.data[0] as TableData,
timeSeries: null,
};
}
return {
timeSeries: response.data as TimeSeries[],
tableData: null,
};
};
render() { render() {
const { queries } = this.props; const { queries } = this.props;
const { response, loading, isFirstLoad } = this.state; const { loading, isFirstLoad } = this.state;
const timeSeries = response.data; const panelData = this.getPanelData();
if (isFirstLoad && loading === LoadingState.Loading) { if (isFirstLoad && loading === LoadingState.Loading) {
return this.renderLoadingStates(); return this.renderLoadingStates();
...@@ -190,8 +215,8 @@ export class DataPanel extends Component<Props, State> { ...@@ -190,8 +215,8 @@ export class DataPanel extends Component<Props, State> {
return ( return (
<> <>
{this.props.children({ {this.props.children({
timeSeries,
loading, loading,
panelData,
})} })}
</> </>
); );
......
...@@ -14,8 +14,7 @@ import { applyPanelTimeOverrides } from 'app/features/dashboard/utils/panel'; ...@@ -14,8 +14,7 @@ import { applyPanelTimeOverrides } from 'app/features/dashboard/utils/panel';
import { PANEL_HEADER_HEIGHT } from 'app/core/constants'; import { PANEL_HEADER_HEIGHT } from 'app/core/constants';
// Types // Types
import { PanelModel } from '../state/PanelModel'; import { DashboardModel, PanelModel } from '../state';
import { DashboardModel } from '../state/DashboardModel';
import { PanelPlugin } from 'app/types'; import { PanelPlugin } from 'app/types';
import { TimeRange } from '@grafana/ui'; import { TimeRange } from '@grafana/ui';
...@@ -139,7 +138,6 @@ export class PanelChrome extends PureComponent<Props, State> { ...@@ -139,7 +138,6 @@ export class PanelChrome extends PureComponent<Props, State> {
scopedVars={panel.scopedVars} scopedVars={panel.scopedVars}
links={panel.links} links={panel.links}
/> />
{panel.snapshotData ? ( {panel.snapshotData ? (
this.renderPanel(false, panel.snapshotData, width, height) this.renderPanel(false, panel.snapshotData, width, height)
) : ( ) : (
...@@ -152,8 +150,8 @@ export class PanelChrome extends PureComponent<Props, State> { ...@@ -152,8 +150,8 @@ export class PanelChrome extends PureComponent<Props, State> {
refreshCounter={refreshCounter} refreshCounter={refreshCounter}
onDataResponse={this.onDataResponse} onDataResponse={this.onDataResponse}
> >
{({ loading, timeSeries }) => { {({ loading, panelData }) => {
return this.renderPanel(loading, timeSeries, width, height); return this.renderPanel(loading, panelData.timeSeries, width, height);
}} }}
</DataPanel> </DataPanel>
)} )}
......
...@@ -101,17 +101,6 @@ export class PanelEditor extends PureComponent<PanelEditorProps> { ...@@ -101,17 +101,6 @@ export class PanelEditor extends PureComponent<PanelEditorProps> {
return ( return (
<div className="panel-editor-container__editor"> <div className="panel-editor-container__editor">
{
// <div className="panel-editor__close">
// <i className="fa fa-arrow-left" />
// </div>
// <div className="panel-editor-resizer">
// <div className="panel-editor-resizer__handle">
// <div className="panel-editor-resizer__handle-dots" />
// </div>
// </div>
}
<div className="panel-editor-tabs"> <div className="panel-editor-tabs">
{tabs.map(tab => { {tabs.map(tab => {
return <TabItem tab={tab} activeTab={activeTab} onClick={this.onChangeTab} key={tab.id} />; return <TabItem tab={tab} activeTab={activeTab} onClick={this.onChangeTab} key={tab.id} />;
......
...@@ -5,6 +5,7 @@ import _ from 'lodash'; ...@@ -5,6 +5,7 @@ import _ from 'lodash';
import { Emitter } from 'app/core/utils/emitter'; import { Emitter } from 'app/core/utils/emitter';
import { PANEL_OPTIONS_KEY_PREFIX } from 'app/core/constants'; import { PANEL_OPTIONS_KEY_PREFIX } from 'app/core/constants';
import { DataQuery, TimeSeries } from '@grafana/ui'; import { DataQuery, TimeSeries } from '@grafana/ui';
import { TableData } from '@grafana/ui/src';
export interface GridPos { export interface GridPos {
x: number; x: number;
...@@ -87,7 +88,7 @@ export class PanelModel { ...@@ -87,7 +88,7 @@ export class PanelModel {
datasource: string; datasource: string;
thresholds?: any; thresholds?: any;
snapshotData?: TimeSeries[]; snapshotData?: TimeSeries[] | [TableData];
timeFrom?: any; timeFrom?: any;
timeShift?: any; timeShift?: any;
hideTimeOverride?: any; hideTimeOverride?: any;
......
...@@ -22,6 +22,7 @@ const newVariable = index => { ...@@ -22,6 +22,7 @@ const newVariable = index => {
}; };
export class ElasticPipelineVariablesCtrl { export class ElasticPipelineVariablesCtrl {
/** @ngInject */
constructor($scope) { constructor($scope) {
$scope.variables = $scope.variables || [newVariable(1)]; $scope.variables = $scope.variables || [newVariable(1)];
......
...@@ -43,6 +43,25 @@ describe('TimeRegionManager', () => { ...@@ -43,6 +43,25 @@ describe('TimeRegionManager', () => {
}); });
} }
describe('When colors missing in config', () => {
plotOptionsScenario('should not throw an error when fillColor is undefined', ctx => {
const regions = [
{ fromDayOfWeek: 1, toDayOfWeek: 1, fill: true, line: true, lineColor: '#ffffff', colorMode: 'custom' },
];
const from = moment('2018-01-01T00:00:00+01:00');
const to = moment('2018-01-01T23:59:00+01:00');
expect(() => ctx.setup(regions, from, to)).not.toThrow();
});
plotOptionsScenario('should not throw an error when lineColor is undefined', ctx => {
const regions = [
{ fromDayOfWeek: 1, toDayOfWeek: 1, fill: true, fillColor: '#ffffff', line: true, colorMode: 'custom' },
];
const from = moment('2018-01-01T00:00:00+01:00');
const to = moment('2018-01-01T23:59:00+01:00');
expect(() => ctx.setup(regions, from, to)).not.toThrow();
});
});
describe('When creating plot markings using local time', () => { describe('When creating plot markings using local time', () => {
plotOptionsScenario('for day of week region', ctx => { plotOptionsScenario('for day of week region', ctx => {
const regions = [{ fromDayOfWeek: 1, toDayOfWeek: 1, fill: true, line: true, colorMode: 'red' }]; const regions = [{ fromDayOfWeek: 1, toDayOfWeek: 1, fill: true, line: true, colorMode: 'red' }];
......
...@@ -50,8 +50,8 @@ function getColor(timeRegion, theme: GrafanaTheme): TimeRegionColorDefinition { ...@@ -50,8 +50,8 @@ function getColor(timeRegion, theme: GrafanaTheme): TimeRegionColorDefinition {
if (timeRegion.colorMode === 'custom') { if (timeRegion.colorMode === 'custom') {
return { return {
fill: getColorFromHexRgbOrName(timeRegion.fillColor, theme), fill: timeRegion.fill && timeRegion.fillColor ? getColorFromHexRgbOrName(timeRegion.fillColor, theme) : null,
line: getColorFromHexRgbOrName(timeRegion.lineColor, theme), line: timeRegion.line && timeRegion.lineColor ? getColorFromHexRgbOrName(timeRegion.lineColor, theme) : null,
}; };
} }
...@@ -62,8 +62,8 @@ function getColor(timeRegion, theme: GrafanaTheme): TimeRegionColorDefinition { ...@@ -62,8 +62,8 @@ function getColor(timeRegion, theme: GrafanaTheme): TimeRegionColorDefinition {
} }
return { return {
fill: getColorFromHexRgbOrName(colorMode.color.fill, theme), fill: timeRegion.fill ? getColorFromHexRgbOrName(colorMode.color.fill, theme) : null,
line: getColorFromHexRgbOrName(colorMode.color.line, theme), line: timeRegion.fill ? getColorFromHexRgbOrName(colorMode.color.line, theme) : null,
}; };
} }
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment