Merge branch 'master' into 14812/formgroup-component

This commit is contained in:
Peter Holmberg
2019-01-15 17:19:13 +00:00
65 changed files with 8828 additions and 3792 deletions
+2 -2
View File
@@ -367,7 +367,7 @@ jobs:
command: './scripts/build/publish.sh --enterprise'
- run:
name: Load GPG private key
comand: './scripts/build/load-signing-key.sh'
command: './scripts/build/load-signing-key.sh'
- run:
name: Update Debian repository
command: './scripts/build/update_repo/update-deb.sh "enterprise" "$GPG_KEY_PASSWORD" "$CIRCLE_TAG"'
@@ -430,7 +430,7 @@ jobs:
command: './scripts/build/publish.sh'
- run:
name: Load GPG private key
comand: './scripts/build/load-signing-key.sh'
command: './scripts/build/load-signing-key.sh'
- run:
name: Update Debian repository
command: './scripts/build/update_repo/update-deb.sh "oss" "$GPG_KEY_PASSWORD" "$CIRCLE_TAG"'
+10 -1
View File
@@ -19,17 +19,26 @@
* **OAuth**: Support OAuth providers that are not RFC6749 compliant [#14562](https://github.com/grafana/grafana/issues/14562), thx [@tdabasinskas](https://github.com/tdabasinskas)
* **Units**: Add blood glucose level units mg/dL and mmol/L [#14519](https://github.com/grafana/grafana/issues/14519), thx [@kjedamzik](https://github.com/kjedamzik)
* **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)
* **Provisioning**: Fixes bug causing infinite growth in dashboard_version table. [#12864](https://github.com/grafana/grafana/issues/12864)
### Bug fixes
* **Search**: Fix for issue with scrolling the "tags filter" dropdown, fixes [#14486](https://github.com/grafana/grafana/issues/14486)
* **Prometheus**: Query for annotation always uses 60s step regardless of dashboard range, fixes [#14795](https://github.com/grafana/grafana/issues/14795)
# 5.4.3 (2019-01-14)
### Tech
* **Docker**: Build and publish docker images for armv7 and arm64 [#14617](https://github.com/grafana/grafana/pull/14617), thx [@johanneswuerbach](https://github.com/johanneswuerbach)
* **Backend**: Upgrade to golang 1.11.4 [#14580](https://github.com/grafana/grafana/issues/14580)
* **MySQL** only update session in mysql database when required [#14540](https://github.com/grafana/grafana/pull/14540)
### Bug fixes
* **Alerting** Invalid frequency causes division by zero in alert scheduler [#14810](https://github.com/grafana/grafana/issues/14810)
* **Dashboard** Dashboard links do not update when time range changes [#14493](https://github.com/grafana/grafana/issues/14493)
* **Limits** Support more than 1000 datasources per org [#13883](https://github.com/grafana/grafana/issues/13883)
* **Backend** fix signed in user for orgId=0 result should return active org id [#14574](https://github.com/grafana/grafana/pull/14574)
* **Provisioning** Adds orgId to user dto for provisioned dashboards [#14678](https://github.com/grafana/grafana/pull/14678)
# 5.4.2 (2018-12-13)
+1 -1
View File
@@ -4,6 +4,6 @@ providers:
- name: 'gdev dashboards'
folder: 'gdev dashboards'
type: file
updateIntervalSeconds: 15
options:
path: devenv/dev-dashboards
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,510 @@
{
"annotations": {
"list": [
{
"builtIn": 1,
"datasource": "-- Grafana --",
"enable": true,
"hide": true,
"iconColor": "rgba(0, 211, 255, 1)",
"name": "Annotations & Alerts",
"type": "dashboard"
}
]
},
"editable": true,
"gnetId": null,
"graphTooltip": 0,
"links": [],
"panels": [
{
"aliasColors": {},
"bars": false,
"dashLength": 10,
"dashes": false,
"datasource": "gdev-testdata",
"fill": 2,
"gridPos": {
"h": 8,
"w": 24,
"x": 0,
"y": 0
},
"id": 2,
"legend": {
"avg": false,
"current": false,
"max": false,
"min": false,
"show": true,
"total": false,
"values": false
},
"lines": true,
"linewidth": 2,
"links": [],
"nullPointMode": "null",
"percentage": false,
"pointradius": 5,
"points": false,
"renderer": "flot",
"seriesOverrides": [],
"spaceLength": 10,
"stack": false,
"steppedLine": false,
"targets": [
{
"refId": "A",
"scenarioId": "random_walk",
"target": ""
}
],
"thresholds": [],
"timeFrom": null,
"timeRegions": [
{
"colorMode": "gray",
"fill": true,
"fillColor": "rgba(255, 255, 255, 0.03)",
"from": "08:30",
"fromDayOfWeek": 1,
"line": false,
"lineColor": "rgba(255, 255, 255, 0.2)",
"op": "time",
"to": "16:45",
"toDayOfWeek": 5
}
],
"timeShift": null,
"title": "Business Hours",
"tooltip": {
"shared": true,
"sort": 0,
"value_type": "individual"
},
"type": "graph",
"xaxis": {
"buckets": null,
"mode": "time",
"name": null,
"show": true,
"values": []
},
"yaxes": [
{
"format": "short",
"label": null,
"logBase": 1,
"max": null,
"min": null,
"show": true
},
{
"format": "short",
"label": null,
"logBase": 1,
"max": null,
"min": null,
"show": true
}
],
"yaxis": {
"align": false,
"alignLevel": null
}
},
{
"aliasColors": {},
"bars": false,
"dashLength": 10,
"dashes": false,
"datasource": "gdev-testdata",
"fill": 2,
"gridPos": {
"h": 8,
"w": 24,
"x": 0,
"y": 8
},
"id": 4,
"legend": {
"avg": false,
"current": false,
"max": false,
"min": false,
"show": true,
"total": false,
"values": false
},
"lines": true,
"linewidth": 2,
"links": [],
"nullPointMode": "null",
"percentage": false,
"pointradius": 5,
"points": false,
"renderer": "flot",
"seriesOverrides": [],
"spaceLength": 10,
"stack": false,
"steppedLine": false,
"targets": [
{
"expr": "",
"format": "time_series",
"intervalFactor": 1,
"refId": "A",
"scenarioId": "random_walk",
"target": ""
}
],
"thresholds": [],
"timeFrom": null,
"timeRegions": [
{
"colorMode": "red",
"fill": true,
"fillColor": "rgba(255, 255, 255, 0.03)",
"from": "20:00",
"fromDayOfWeek": 7,
"line": false,
"lineColor": "rgba(255, 255, 255, 0.2)",
"op": "time",
"to": "23:00",
"toDayOfWeek": 7
}
],
"timeShift": null,
"title": "Sunday's 20-23",
"tooltip": {
"shared": true,
"sort": 0,
"value_type": "individual"
},
"type": "graph",
"xaxis": {
"buckets": null,
"mode": "time",
"name": null,
"show": true,
"values": []
},
"yaxes": [
{
"format": "short",
"label": null,
"logBase": 1,
"max": null,
"min": null,
"show": true
},
{
"format": "short",
"label": null,
"logBase": 1,
"max": null,
"min": null,
"show": true
}
],
"yaxis": {
"align": false,
"alignLevel": null
}
},
{
"aliasColors": {
"A-series": "#d683ce"
},
"bars": false,
"dashLength": 10,
"dashes": false,
"datasource": "gdev-testdata",
"fill": 2,
"gridPos": {
"h": 8,
"w": 24,
"x": 0,
"y": 16
},
"id": 3,
"legend": {
"avg": false,
"current": false,
"max": false,
"min": false,
"show": true,
"total": false,
"values": false
},
"lines": true,
"linewidth": 2,
"links": [],
"nullPointMode": "null",
"percentage": false,
"pointradius": 0.5,
"points": false,
"renderer": "flot",
"seriesOverrides": [],
"spaceLength": 10,
"stack": false,
"steppedLine": false,
"targets": [
{
"refId": "A",
"scenarioId": "random_walk",
"target": ""
}
],
"thresholds": [],
"timeFrom": null,
"timeRegions": [
{
"colorMode": "custom",
"fill": true,
"fillColor": "rgba(255, 0, 0, 0.22)",
"from": "",
"fromDayOfWeek": 1,
"line": true,
"lineColor": "rgba(255, 0, 0, 0.32)",
"op": "time",
"to": "",
"toDayOfWeek": 1
},
{
"colorMode": "custom",
"fill": true,
"fillColor": "rgba(255, 127, 0, 0.22)",
"fromDayOfWeek": 2,
"line": true,
"lineColor": "rgba(255, 127, 0, 0.32)",
"op": "time",
"toDayOfWeek": 2
},
{
"colorMode": "custom",
"fill": true,
"fillColor": "rgba(255, 255, 0, 0.22)",
"fromDayOfWeek": 3,
"line": true,
"lineColor": "rgba(255, 255, 0, 0.22)",
"op": "time",
"toDayOfWeek": 3
},
{
"colorMode": "custom",
"fill": true,
"fillColor": "rgba(0, 255, 0, 0.22)",
"fromDayOfWeek": 4,
"line": true,
"lineColor": "rgba(0, 255, 0, 0.32)",
"op": "time",
"toDayOfWeek": 4
},
{
"colorMode": "custom",
"fill": true,
"fillColor": "rgba(0, 0, 255, 0.22)",
"fromDayOfWeek": 5,
"line": true,
"lineColor": "rgba(0, 0, 255, 0.32)",
"op": "time",
"toDayOfWeek": 5
},
{
"colorMode": "custom",
"fill": true,
"fillColor": "rgba(75, 0, 130, 0.22)",
"fromDayOfWeek": 6,
"line": true,
"lineColor": "rgba(75, 0, 130, 0.32)",
"op": "time",
"toDayOfWeek": 6
},
{
"colorMode": "custom",
"fill": true,
"fillColor": "rgba(148, 0, 211, 0.22)",
"fromDayOfWeek": 7,
"line": true,
"lineColor": "rgba(148, 0, 211, 0.32)",
"op": "time",
"toDayOfWeek": 7
}
],
"timeShift": null,
"title": "Each day of week",
"tooltip": {
"shared": true,
"sort": 0,
"value_type": "individual"
},
"type": "graph",
"xaxis": {
"buckets": null,
"mode": "time",
"name": null,
"show": true,
"values": []
},
"yaxes": [
{
"format": "short",
"label": null,
"logBase": 1,
"max": null,
"min": null,
"show": true
},
{
"format": "short",
"label": null,
"logBase": 1,
"max": null,
"min": null,
"show": true
}
],
"yaxis": {
"align": false,
"alignLevel": null
}
},
{
"aliasColors": {},
"bars": false,
"dashLength": 10,
"dashes": false,
"datasource": "gdev-testdata",
"fill": 2,
"gridPos": {
"h": 8,
"w": 24,
"x": 0,
"y": 24
},
"id": 5,
"legend": {
"avg": false,
"current": false,
"max": false,
"min": false,
"show": true,
"total": false,
"values": false
},
"lines": true,
"linewidth": 2,
"links": [],
"nullPointMode": "null",
"percentage": false,
"pointradius": 5,
"points": false,
"renderer": "flot",
"seriesOverrides": [],
"spaceLength": 10,
"stack": false,
"steppedLine": false,
"targets": [
{
"expr": "",
"format": "time_series",
"intervalFactor": 1,
"refId": "A",
"scenarioId": "random_walk",
"target": ""
}
],
"thresholds": [],
"timeFrom": null,
"timeRegions": [
{
"colorMode": "red",
"fill": false,
"from": "05:00",
"line": true,
"op": "time"
}
],
"timeShift": null,
"title": "05:00",
"tooltip": {
"shared": true,
"sort": 0,
"value_type": "individual"
},
"type": "graph",
"xaxis": {
"buckets": null,
"mode": "time",
"name": null,
"show": true,
"values": []
},
"yaxes": [
{
"format": "short",
"label": null,
"logBase": 1,
"max": null,
"min": null,
"show": true
},
{
"format": "short",
"label": null,
"logBase": 1,
"max": null,
"min": null,
"show": true
}
],
"yaxis": {
"align": false,
"alignLevel": null
}
}
],
"refresh": false,
"schemaVersion": 16,
"style": "dark",
"tags": [
"gdev",
"panel-tests"
],
"templating": {
"list": []
},
"time": {
"from": "now-30d",
"to": "now"
},
"timepicker": {
"refresh_intervals": [
"5s",
"10s",
"30s",
"1m",
"5m",
"15m",
"30m",
"1h",
"2h",
"1d"
],
"time_options": [
"5m",
"15m",
"1h",
"6h",
"12h",
"24h",
"2d",
"7d",
"30d"
]
},
"timezone": "browser",
"title": "Panel Tests - Graph (Time Regions)",
"version": 1
}
File diff suppressed because it is too large Load Diff
+6 -1
View File
@@ -1,5 +1,6 @@
+++
title = "Explore"
keywords = ["explore", "loki", "logs"]
type = "docs"
[menu.docs]
name = "Explore"
@@ -8,7 +9,11 @@ parent = "features"
weight = 5
+++
# Introduction
# Explore
> Explore is only available in Grafana 6.0 and above.
## Introduction
One of the major new features of Grafana 6.0 is the new query-focused Explore workflow for troubleshooting and/or for data exploration.
+2 -2
View File
@@ -1,4 +1,4 @@
{
"stable": "5.4.2",
"testing": "5.4.2"
"stable": "5.4.3",
"testing": "5.4.3"
}
@@ -1,7 +1,7 @@
import React from 'react';
import _ from 'lodash';
import $ from 'jquery';
import 'vendor/spectrum';
import '../../vendor/spectrum';
export interface Props {
color: string;
@@ -12,10 +12,7 @@ export class Portal extends PureComponent<Props> {
constructor(props: Props) {
super(props);
const {
className,
root = document.body
} = this.props;
const { className, root = document.body } = this.props;
if (className) {
this.node.classList.add(className);
@@ -4,6 +4,7 @@ import SelectOption from './SelectOption';
import { OptionProps } from 'react-select/lib/components/Option';
const model: OptionProps<any> = {
data: jest.fn(),
cx: jest.fn(),
clearValue: jest.fn(),
getStyles: jest.fn(),
@@ -2,7 +2,11 @@ import React, { PureComponent } from 'react';
import { GroupProps } from 'react-select/lib/components/Group';
interface ExtendedGroupProps extends GroupProps<any> {
data: any;
data: {
label: string;
expanded: boolean;
options: any[];
};
}
interface State {
@@ -15,8 +19,10 @@ export default class SelectOptionGroup extends PureComponent<ExtendedGroupProps,
};
componentDidMount() {
if (this.props.selectProps) {
const value = this.props.selectProps.value[this.props.selectProps.value.length - 1];
if (this.props.data.expanded) {
this.setState({ expanded: true });
} else if (this.props.selectProps && this.props.selectProps.value) {
const { value } = this.props.selectProps.value;
if (value && this.props.options.some(option => option.value === value)) {
this.setState({ expanded: true });
@@ -63,6 +63,7 @@ $select-input-bg-disabled: $input-bg-disabled;
.gf-form-select-box__menu-list {
overflow-y: auto;
max-height: 300px;
max-width: 600px;
}
.tag-filter .gf-form-select-box__menu {
@@ -5,3 +5,4 @@
@import 'Select/Select';
@import 'PanelOptionsGroup/PanelOptionsGroup';
@import 'PanelOptionsGrid/PanelOptionsGrid';
@import 'ColorPicker/ColorPicker';
+2
View File
@@ -1 +1,3 @@
@import 'vendor/spectrum';
@import 'components/index';
@@ -1,6 +1,7 @@
package dashboards
import (
"fmt"
"io/ioutil"
"os"
"path/filepath"
@@ -69,7 +70,7 @@ func (cr *configReader) readConfig() ([]*DashboardsAsConfig, error) {
parsedDashboards, err := cr.parseConfigs(file)
if err != nil {
return nil, err
return nil, fmt.Errorf("could not parse provisioning config file: %s error: %v", file.Name(), err)
}
if len(parsedDashboards) > 0 {
@@ -118,6 +118,7 @@ func (fr *fileReader) startWalkingDisk() error {
return nil
}
func (fr *fileReader) deleteDashboardIfFileIsMissing(provisionedDashboardRefs map[string]*models.DashboardProvisioning, filesFoundOnDisk map[string]os.FileInfo) {
if fr.Cfg.DisableDeletion {
return
@@ -180,7 +181,7 @@ func (fr *fileReader) saveDashboard(path string, folderId int64, fileInfo os.Fil
dash.Dashboard.SetId(provisionedData.DashboardId)
}
fr.log.Debug("saving new dashboard", "file", path)
fr.log.Debug("saving new dashboard", "provisoner", fr.Cfg.Name, "file", path, "folderId", dash.Dashboard.FolderId)
dp := &models.DashboardProvisioning{
ExternalId: path,
Name: fr.Cfg.Name,
@@ -166,6 +166,36 @@ func TestDashboardFileReader(t *testing.T) {
_, err := NewDashboardFileReader(cfg, logger)
So(err, ShouldBeNil)
})
Convey("Two dashboard providers should be able to provisioned the same dashboard without uid", func() {
cfg1 := &DashboardsAsConfig{Name: "1", Type: "file", OrgId: 1, Folder: "f1", Options: map[string]interface{}{"path": containingId}}
cfg2 := &DashboardsAsConfig{Name: "2", Type: "file", OrgId: 1, Folder: "f2", Options: map[string]interface{}{"path": containingId}}
reader1, err := NewDashboardFileReader(cfg1, logger)
So(err, ShouldBeNil)
err = reader1.startWalkingDisk()
So(err, ShouldBeNil)
reader2, err := NewDashboardFileReader(cfg2, logger)
So(err, ShouldBeNil)
err = reader2.startWalkingDisk()
So(err, ShouldBeNil)
var folderCount int
var dashCount int
for _, o := range fakeService.inserted {
if o.Dashboard.IsFolder {
folderCount++
} else {
dashCount++
}
}
So(folderCount, ShouldEqual, 2)
So(dashCount, ShouldEqual, 2)
})
})
Convey("Should not create new folder if folder name is missing", func() {
@@ -256,7 +286,9 @@ func (ffi FakeFileInfo) Sys() interface{} {
}
func mockDashboardProvisioningService() *fakeDashboardProvisioningService {
mock := fakeDashboardProvisioningService{}
mock := fakeDashboardProvisioningService{
provisioned: map[string][]*models.DashboardProvisioning{},
}
dashboards.NewProvisioningService = func() dashboards.DashboardProvisioningService {
return &mock
}
@@ -265,17 +297,26 @@ func mockDashboardProvisioningService() *fakeDashboardProvisioningService {
type fakeDashboardProvisioningService struct {
inserted []*dashboards.SaveDashboardDTO
provisioned []*models.DashboardProvisioning
provisioned map[string][]*models.DashboardProvisioning
getDashboard []*models.Dashboard
}
func (s *fakeDashboardProvisioningService) GetProvisionedDashboardData(name string) ([]*models.DashboardProvisioning, error) {
return s.provisioned, nil
if _, ok := s.provisioned[name]; !ok {
s.provisioned[name] = []*models.DashboardProvisioning{}
}
return s.provisioned[name], nil
}
func (s *fakeDashboardProvisioningService) SaveProvisionedDashboard(dto *dashboards.SaveDashboardDTO, provisioning *models.DashboardProvisioning) (*models.Dashboard, error) {
s.inserted = append(s.inserted, dto)
s.provisioned = append(s.provisioned, provisioning)
if _, ok := s.provisioned[provisioning.Name]; !ok {
s.provisioned[provisioning.Name] = []*models.DashboardProvisioning{}
}
s.provisioned[provisioning.Name] = append(s.provisioned[provisioning.Name], provisioning)
return dto.Dashboard, nil
}
@@ -51,7 +51,7 @@ func SaveProvisionedDashboard(cmd *models.SaveProvisionedDashboardCommand) error
func saveProvionedData(sess *DBSession, cmd *models.DashboardProvisioning, dashboard *models.Dashboard) error {
result := &models.DashboardProvisioning{}
exist, err := sess.Where("dashboard_id=?", dashboard.Id).Get(result)
exist, err := sess.Where("dashboard_id=? AND name = ?", dashboard.Id, cmd.Name).Get(result)
if err != nil {
return err
}
+27
View File
@@ -1,10 +1,13 @@
import { react2AngularDirective } from 'app/core/utils/react2angular';
import { QueryEditor as StackdriverQueryEditor } from 'app/plugins/datasource/stackdriver/components/QueryEditor';
import { AnnotationQueryEditor as StackdriverAnnotationQueryEditor } from 'app/plugins/datasource/stackdriver/components/AnnotationQueryEditor';
import { PasswordStrength } from './components/PasswordStrength';
import PageHeader from './components/PageHeader/PageHeader';
import EmptyListCTA from './components/EmptyListCTA/EmptyListCTA';
import { SearchResult } from './components/search/SearchResult';
import { TagFilter } from './components/TagFilter/TagFilter';
import { SideMenu } from './components/sidemenu/SideMenu';
import { MetricSelect } from './components/Select/MetricSelect';
import AppNotificationList from './components/AppNotifications/AppNotificationList';
import { ColorPicker, SeriesColorPickerPopover } from '@grafana/ui';
@@ -29,4 +32,28 @@ export function registerAngularDirectives() {
'onColorChange',
'onToggleAxis',
]);
react2AngularDirective('metricSelect', MetricSelect, [
'options',
'onChange',
'value',
'isSearchable',
'className',
'placeholder',
['variables', { watchDepth: 'reference' }],
]);
react2AngularDirective('stackdriverQueryEditor', StackdriverQueryEditor, [
'target',
'onQueryChange',
'onExecuteQuery',
['events', { watchDepth: 'reference' }],
['datasource', { watchDepth: 'reference' }],
['templateSrv', { watchDepth: 'reference' }],
]);
react2AngularDirective('stackdriverAnnotationQueryEditor', StackdriverAnnotationQueryEditor, [
'target',
'onQueryChange',
'onExecuteQuery',
['datasource', { watchDepth: 'reference' }],
['templateSrv', { watchDepth: 'reference' }],
]);
}
@@ -0,0 +1,90 @@
import React from 'react';
import _ from 'lodash';
import { Select } from '@grafana/ui';
import { SelectOptionItem } from '@grafana/ui';
import { Variable } from 'app/types/templates';
export interface Props {
onChange: (value: string) => void;
options: SelectOptionItem[];
isSearchable: boolean;
value: string;
placeholder?: string;
className?: string;
variables?: Variable[];
}
interface State {
options: any[];
}
export class MetricSelect extends React.Component<Props, State> {
static defaultProps = {
variables: [],
options: [],
isSearchable: true,
};
constructor(props) {
super(props);
this.state = { options: [] };
}
componentDidMount() {
this.setState({ options: this.buildOptions(this.props) });
}
componentWillReceiveProps(nextProps: Props) {
if (nextProps.options.length > 0 || nextProps.variables.length) {
this.setState({ options: this.buildOptions(nextProps) });
}
}
shouldComponentUpdate(nextProps: Props) {
const nextOptions = this.buildOptions(nextProps);
return nextProps.value !== this.props.value || !_.isEqual(nextOptions, this.state.options);
}
buildOptions({ variables = [], options }) {
return variables.length > 0 ? [this.getVariablesGroup(), ...options] : options;
}
getVariablesGroup() {
return {
label: 'Template Variables',
options: this.props.variables.map(v => ({
label: `$${v.name}`,
value: `$${v.name}`,
})),
};
}
getSelectedOption() {
const { options } = this.state;
const allOptions = options.every(o => o.options) ? _.flatten(options.map(o => o.options)) : options;
return allOptions.find(option => option.value === this.props.value);
}
render() {
const { placeholder, className, isSearchable, onChange } = this.props;
const { options } = this.state;
const selectedOption = this.getSelectedOption();
return (
<Select
className={className}
isMulti={false}
isClearable={false}
backspaceRemovesValue={false}
onChange={item => onChange(item.value)}
options={options}
isSearchable={isSearchable}
maxMenuHeight={500}
placeholder={placeholder}
noOptionsMessage={() => 'No options found'}
value={selectedOption}
/>
);
}
}
+1
View File
@@ -11,3 +11,4 @@ import './alerting/NotificationsListCtrl';
import './manage-dashboards';
import './teams/CreateTeamCtrl';
import './profile/all';
import './datasources/settings/HttpSettingsCtrl';
@@ -0,0 +1,26 @@
import { coreModule } from 'app/core/core';
coreModule.directive('datasourceHttpSettings', () => {
return {
scope: {
current: '=',
suggestUrl: '@',
noDirectAccess: '@',
},
templateUrl: 'public/app/features/datasources/partials/http_settings.html',
link: {
pre: ($scope, elem, attrs) => {
// do not show access option if direct access is disabled
$scope.showAccessOption = $scope.noDirectAccess !== 'true';
$scope.showAccessHelp = false;
$scope.toggleAccessHelp = () => {
$scope.showAccessHelp = !$scope.showAccessHelp;
};
$scope.getSuggestUrls = () => {
return [$scope.suggestUrl];
};
},
},
};
});
@@ -219,8 +219,9 @@ export class PrometheusDatasource {
};
const range = Math.ceil(end - start);
// options.interval is the dynamically calculated interval
let interval = kbn.interval_to_seconds(options.interval);
// Minimum interval ("Min step"), if specified for the query. or same as interval otherwise
// Minimum interval ("Min step"), if specified for the query or datasource. or same as interval otherwise
const minInterval = kbn.interval_to_seconds(
this.templateSrv.replace(target.interval, options.scopedVars) || options.interval
);
@@ -366,12 +367,13 @@ export class PrometheusDatasource {
const step = annotation.step || '60s';
const start = this.getPrometheusTime(options.range.from, false);
const end = this.getPrometheusTime(options.range.to, true);
// Unsetting min interval
const queryOptions = {
...options,
interval: '0s',
interval: step,
};
const query = this.createQuery({ expr, interval: step }, queryOptions, start, end);
// Unsetting min interval for accurate event resolution
const minStep = '1s';
const query = this.createQuery({ expr, interval: minStep }, queryOptions, start, end);
const self = this;
return this.performTimeSeriesQuery(query, query.start, query.end).then(results => {
@@ -577,6 +577,79 @@ describe('PrometheusDatasource', () => {
expect(results[0].time).toEqual(1);
});
});
describe('step parameter', () => {
beforeEach(() => {
backendSrv.datasourceRequest = jest.fn(() => Promise.resolve(response));
ctx.ds = new PrometheusDatasource(instanceSettings, q, backendSrv as any, templateSrv, timeSrv);
});
it('should use default step for short range if no interval is given', () => {
const query = {
...options,
range: {
from: time({ seconds: 63 }),
to: time({ seconds: 123 }),
},
};
ctx.ds.annotationQuery(query);
const req = backendSrv.datasourceRequest.mock.calls[0][0];
expect(req.url).toContain('step=60');
});
it('should use custom step for short range', () => {
const annotation = {
...options.annotation,
step: '10s',
};
const query = {
...options,
annotation,
range: {
from: time({ seconds: 63 }),
to: time({ seconds: 123 }),
},
};
ctx.ds.annotationQuery(query);
const req = backendSrv.datasourceRequest.mock.calls[0][0];
expect(req.url).toContain('step=10');
});
it('should use custom step for short range', () => {
const annotation = {
...options.annotation,
step: '10s',
};
const query = {
...options,
annotation,
range: {
from: time({ seconds: 63 }),
to: time({ seconds: 123 }),
},
};
ctx.ds.annotationQuery(query);
const req = backendSrv.datasourceRequest.mock.calls[0][0];
expect(req.url).toContain('step=10');
});
it('should use dynamic step on long ranges if no option was given', () => {
const query = {
...options,
range: {
from: time({ seconds: 63 }),
to: time({ hours: 24 * 30, seconds: 63 }),
},
};
ctx.ds.annotationQuery(query);
const req = backendSrv.datasourceRequest.mock.calls[0][0];
// Range in seconds: (to - from) / 1000
// Max_datapoints: 11000
// Step: range / max_datapoints
const step = 236;
expect(req.url).toContain(`step=${step}`);
});
});
});
describe('When resultFormat is table and instant = true', () => {
@@ -1,31 +1,18 @@
import _ from 'lodash';
import './query_filter_ctrl';
import { TemplateSrv } from 'app/features/templating/template_srv';
export class StackdriverAnnotationsQueryCtrl {
static templateUrl = 'partials/annotations.editor.html';
annotation: any;
datasource: any;
defaultDropdownValue = 'Select Metric';
defaultServiceValue = 'All Services';
defaults = {
project: {
id: 'default',
name: 'loading project...',
},
metricType: this.defaultDropdownValue,
service: this.defaultServiceValue,
metric: '',
filters: [],
metricKind: '',
valueType: '',
};
templateSrv: TemplateSrv;
/** @ngInject */
constructor() {
constructor(templateSrv) {
this.templateSrv = templateSrv;
this.annotation.target = this.annotation.target || {};
this.annotation.target.refId = 'annotationQuery';
_.defaultsDeep(this.annotation.target, this.defaults);
this.onQueryChange = this.onQueryChange.bind(this);
}
onQueryChange(target) {
Object.assign(this.annotation.target, target);
}
}
@@ -0,0 +1,57 @@
import React from 'react';
import renderer from 'react-test-renderer';
import { Aggregations, Props } from './Aggregations';
import { shallow } from 'enzyme';
import { ValueTypes, MetricKind } from '../constants';
import { TemplateSrvStub } from 'test/specs/helpers';
const props: Props = {
onChange: () => {},
templateSrv: new TemplateSrvStub(),
metricDescriptor: {
valueType: '',
metricKind: '',
},
crossSeriesReducer: '',
groupBys: [],
children: renderProps => <div />,
};
describe('Aggregations', () => {
let wrapper;
it('renders correctly', () => {
const tree = renderer.create(<Aggregations {...props} />).toJSON();
expect(tree).toMatchSnapshot();
});
describe('options', () => {
describe('when DOUBLE and DELTA is passed as props', () => {
beforeEach(() => {
const newProps = { ...props, metricDescriptor: { valueType: ValueTypes.DOUBLE, metricKind: MetricKind.GAUGE } };
wrapper = shallow(<Aggregations {...newProps} />);
});
it('', () => {
const options = wrapper.state().aggOptions[0].options;
expect(options.length).toEqual(11);
expect(options.map(o => o.value)).toEqual(
expect.not.arrayContaining(['REDUCE_COUNT_TRUE', 'REDUCE_COUNT_FALSE'])
);
});
});
describe('when MONEY and CUMULATIVE is passed as props', () => {
beforeEach(() => {
const newProps = {
...props,
metricDescriptor: { valueType: ValueTypes.MONEY, metricKind: MetricKind.CUMULATIVE },
};
wrapper = shallow(<Aggregations {...newProps} />);
});
it('', () => {
const options = wrapper.state().aggOptions[0].options;
expect(options.length).toEqual(5);
expect(options.map(o => o.value)).toEqual(expect.arrayContaining(['REDUCE_NONE']));
});
});
});
});
@@ -0,0 +1,94 @@
import React from 'react';
import _ from 'lodash';
import { MetricSelect } from 'app/core/components/Select/MetricSelect';
import { getAggregationOptionsByMetric } from '../functions';
import { TemplateSrv } from 'app/features/templating/template_srv';
export interface Props {
onChange: (metricDescriptor) => void;
templateSrv: TemplateSrv;
metricDescriptor: {
valueType: string;
metricKind: string;
};
crossSeriesReducer: string;
groupBys: string[];
children?: (renderProps: any) => JSX.Element;
}
export interface State {
aggOptions: any[];
displayAdvancedOptions: boolean;
}
export class Aggregations extends React.Component<Props, State> {
state: State = {
aggOptions: [],
displayAdvancedOptions: false,
};
componentDidMount() {
this.setAggOptions(this.props);
}
componentWillReceiveProps(nextProps: Props) {
this.setAggOptions(nextProps);
}
setAggOptions({ metricDescriptor }: Props) {
let aggOptions = [];
if (metricDescriptor) {
aggOptions = [
{
label: 'Aggregations',
expanded: true,
options: getAggregationOptionsByMetric(metricDescriptor.valueType, metricDescriptor.metricKind).map(a => ({
...a,
label: a.text,
})),
},
];
}
this.setState({ aggOptions });
}
onToggleDisplayAdvanced = () => {
this.setState(state => ({
displayAdvancedOptions: !state.displayAdvancedOptions,
}));
};
render() {
const { displayAdvancedOptions, aggOptions } = this.state;
const { templateSrv, onChange, crossSeriesReducer } = this.props;
return (
<>
<div className="gf-form-inline">
<div className="gf-form">
<label className="gf-form-label query-keyword width-9">Aggregation</label>
<MetricSelect
onChange={onChange}
value={crossSeriesReducer}
variables={templateSrv.variables}
options={aggOptions}
placeholder="Select Aggregation"
className="width-15"
/>
</div>
<div className="gf-form gf-form--grow">
<label className="gf-form-label gf-form-label--grow">
<a onClick={this.onToggleDisplayAdvanced}>
<>
<i className={`fa fa-caret-${displayAdvancedOptions ? 'down' : 'right'}`} /> Advanced Options
</>
</a>
</label>
</div>
</div>
{this.props.children(this.state.displayAdvancedOptions)}
</>
);
}
}
@@ -0,0 +1,52 @@
import React, { Component } from 'react';
import { debounce } from 'lodash';
export interface Props {
onChange: (alignmentPeriod) => void;
value: string;
}
export interface State {
value: string;
}
export class AliasBy extends Component<Props, State> {
propagateOnChange: (value) => void;
constructor(props) {
super(props);
this.propagateOnChange = debounce(this.props.onChange, 500);
this.state = { value: '' };
}
componentDidMount() {
this.setState({ value: this.props.value });
}
componentWillReceiveProps(nextProps: Props) {
if (nextProps.value !== this.props.value) {
this.setState({ value: nextProps.value });
}
}
onChange = e => {
this.setState({ value: e.target.value });
this.propagateOnChange(e.target.value);
};
render() {
return (
<>
<div className="gf-form-inline">
<div className="gf-form">
<label className="gf-form-label query-keyword width-9">Alias By</label>
<input type="text" className="gf-form-input width-24" value={this.state.value} onChange={this.onChange} />
</div>
<div className="gf-form gf-form--grow">
<div className="gf-form-label gf-form-label--grow" />
</div>
</div>
</>
);
}
}
@@ -0,0 +1,56 @@
import React, { SFC } from 'react';
import _ from 'lodash';
import kbn from 'app/core/utils/kbn';
import { MetricSelect } from 'app/core/components/Select/MetricSelect';
import { alignmentPeriods, alignOptions } from '../constants';
import { TemplateSrv } from 'app/features/templating/template_srv';
export interface Props {
onChange: (alignmentPeriod) => void;
templateSrv: TemplateSrv;
alignmentPeriod: string;
perSeriesAligner: string;
usedAlignmentPeriod: string;
}
export const AlignmentPeriods: SFC<Props> = ({
alignmentPeriod,
templateSrv,
onChange,
perSeriesAligner,
usedAlignmentPeriod,
}) => {
const alignment = alignOptions.find(ap => ap.value === templateSrv.replace(perSeriesAligner));
const formatAlignmentText = `${kbn.secondsToHms(usedAlignmentPeriod)} interval (${alignment ? alignment.text : ''})`;
return (
<>
<div className="gf-form-inline">
<div className="gf-form">
<label className="gf-form-label query-keyword width-9">Alignment Period</label>
<MetricSelect
onChange={onChange}
value={alignmentPeriod}
variables={templateSrv.variables}
options={[
{
label: 'Alignment options',
expanded: true,
options: alignmentPeriods.map(ap => ({
...ap,
label: ap.text,
})),
},
]}
placeholder="Select Alignment"
className="width-15"
/>
</div>
<div className="gf-form gf-form--grow">
{usedAlignmentPeriod && <label className="gf-form-label gf-form-label--grow">{formatAlignmentText}</label>}
</div>
</div>
</>
);
};
@@ -0,0 +1,33 @@
import React, { SFC } from 'react';
import _ from 'lodash';
import { MetricSelect } from 'app/core/components/Select/MetricSelect';
import { TemplateSrv } from 'app/features/templating/template_srv';
import { SelectOptionItem } from '@grafana/ui';
export interface Props {
onChange: (perSeriesAligner) => void;
templateSrv: TemplateSrv;
alignOptions: SelectOptionItem[];
perSeriesAligner: string;
}
export const Alignments: SFC<Props> = ({ perSeriesAligner, templateSrv, onChange, alignOptions }) => {
return (
<>
<div className="gf-form-group">
<div className="gf-form offset-width-9">
<label className="gf-form-label query-keyword width-15">Aligner</label>
<MetricSelect
onChange={onChange}
value={perSeriesAligner}
variables={templateSrv.variables}
options={alignOptions}
placeholder="Select Alignment"
className="width-15"
/>
</div>
</div>
</>
);
};
@@ -0,0 +1,119 @@
import React from 'react';
import _ from 'lodash';
import { TemplateSrv } from 'app/features/templating/template_srv';
import StackdriverDatasource from '../datasource';
import { Metrics } from './Metrics';
import { Filter } from './Filter';
import { AnnotationTarget } from '../types';
import { AnnotationsHelp } from './AnnotationsHelp';
export interface Props {
onQueryChange: (target: AnnotationTarget) => void;
target: AnnotationTarget;
datasource: StackdriverDatasource;
templateSrv: TemplateSrv;
}
interface State extends AnnotationTarget {
[key: string]: any;
}
const DefaultTarget: State = {
defaultProject: 'loading project...',
metricType: '',
filters: [],
metricKind: '',
valueType: '',
refId: 'annotationQuery',
title: '',
text: '',
};
export class AnnotationQueryEditor extends React.Component<Props, State> {
state: State = DefaultTarget;
componentDidMount() {
this.setState({
...this.props.target,
});
}
onMetricTypeChange = ({ valueType, metricKind, type, unit }) => {
const { onQueryChange } = this.props;
this.setState(
{
metricType: type,
unit,
valueType,
metricKind,
},
() => {
onQueryChange(this.state);
}
);
};
onChange(prop, value) {
this.setState({ [prop]: value }, () => {
this.props.onQueryChange(this.state);
});
}
render() {
const { defaultProject, metricType, filters, refId, title, text } = this.state;
const { datasource, templateSrv } = this.props;
return (
<>
<Metrics
defaultProject={defaultProject}
metricType={metricType}
templateSrv={templateSrv}
datasource={datasource}
onChange={this.onMetricTypeChange}
>
{metric => (
<>
<Filter
filtersChanged={value => this.onChange('filters', value)}
filters={filters}
refId={refId}
hideGroupBys={true}
templateSrv={templateSrv}
datasource={datasource}
metricType={metric ? metric.type : ''}
/>
</>
)}
</Metrics>
<div className="gf-form gf-form-inline">
<div className="gf-form">
<span className="gf-form-label query-keyword width-9">Title</span>
<input
type="text"
className="gf-form-input width-20"
value={title}
onChange={e => this.onChange('title', e.target.value)}
/>
</div>
<div className="gf-form">
<span className="gf-form-label query-keyword width-9">Text</span>
<input
type="text"
className="gf-form-input width-20"
value={text}
onChange={e => this.onChange('text', e.target.value)}
/>
</div>
<div className="gf-form gf-form--grow">
<div className="gf-form-label gf-form-label--grow" />
</div>
</div>
<AnnotationsHelp />
</>
);
}
}
@@ -0,0 +1,44 @@
import React, { SFC } from 'react';
export const AnnotationsHelp: SFC = () => {
return (
<div className="gf-form grafana-info-box" style={{ padding: 0 }}>
<pre className="gf-form-pre alert alert-info" style={{ marginRight: 0 }}>
<h5>Annotation Query Format</h5>
<p>
An annotation is an event that is overlaid on top of graphs. Annotation rendering is expensive so it is
important to limit the number of rows returned.{' '}
</p>
<p>
The Title and Text fields support templating and can use data returned from the query. For example, the Title
field could have the following text:
</p>
<code>
{`${'{{metric.type}}'}`} has value: {`${'{{metric.value}}'}`}
</code>
<p>
Example Result: <code>monitoring.googleapis.com/uptime_check/http_status has this value: 502</code>
</p>
<label>Patterns:</label>
<p>
<code>{`${'{{metric.value}}'}`}</code> = value of the metric/point
</p>
<p>
<code>{`${'{{metric.type}}'}`}</code> = metric type e.g. compute.googleapis.com/instance/cpu/usage_time
</p>
<p>
<code>{`${'{{metric.name}}'}`}</code> = name part of metric e.g. instance/cpu/usage_time
</p>
<p>
<code>{`${'{{metric.service}}'}`}</code> = service part of metric e.g. compute
</p>
<p>
<code>{`${'{{metric.label.label_name}}'}`}</code> = Metric label metadata e.g. metric.label.instance_name
</p>
<p>
<code>{`${'{{resource.label.label_name}}'}`}</code> = Resource label metadata e.g. resource.label.zone
</p>
</pre>
</div>
);
};
@@ -0,0 +1,115 @@
import React from 'react';
import _ from 'lodash';
import appEvents from 'app/core/app_events';
import { QueryMeta } from '../types';
import { getAngularLoader, AngularComponent } from 'app/core/services/AngularLoader';
import { TemplateSrv } from 'app/features/templating/template_srv';
import StackdriverDatasource from '../datasource';
import '../query_filter_ctrl';
export interface Props {
filtersChanged: (filters: string[]) => void;
groupBysChanged?: (groupBys: string[]) => void;
metricType: string;
templateSrv: TemplateSrv;
groupBys?: string[];
filters: string[];
datasource: StackdriverDatasource;
refId: string;
hideGroupBys: boolean;
}
interface State {
labelData: QueryMeta;
loading: Promise<any>;
}
const labelData = {
metricLabels: {},
resourceLabels: {},
resourceTypes: [],
};
export class Filter extends React.Component<Props, State> {
element: any;
component: AngularComponent;
async componentDidMount() {
if (!this.element) {
return;
}
const { groupBys, filters, hideGroupBys } = this.props;
const loader = getAngularLoader();
const filtersChanged = filters => {
this.props.filtersChanged(filters);
};
const groupBysChanged = groupBys => {
this.props.groupBysChanged(groupBys);
};
const scopeProps = {
loading: null,
labelData,
groupBys,
filters,
filtersChanged,
groupBysChanged,
hideGroupBys,
};
const loading = this.loadLabels(scopeProps);
scopeProps.loading = loading;
const template = `<stackdriver-filter
filters="filters"
group-bys="groupBys"
label-data="labelData"
loading="loading"
filters-changed="filtersChanged(filters)"
group-bys-changed="groupBysChanged(groupBys)"
hide-group-bys="hideGroupBys"/>`;
this.component = loader.load(this.element, scopeProps, template);
}
componentDidUpdate(prevProps: Props) {
if (!this.element) {
return;
}
const scope = this.component.getScope();
if (prevProps.metricType !== this.props.metricType) {
scope.loading = this.loadLabels(scope);
}
scope.filters = this.props.filters;
scope.groupBys = this.props.groupBys;
}
componentWillUnmount() {
if (this.component) {
this.component.destroy();
}
}
async loadLabels(scope) {
return new Promise(async resolve => {
try {
if (!this.props.metricType) {
scope.labelData = labelData;
} else {
const { meta } = await this.props.datasource.getLabels(this.props.metricType, this.props.refId);
scope.labelData = meta;
}
resolve();
} catch (error) {
appEvents.emit('alert-error', ['Error', 'Error loading metric labels for ' + this.props.metricType]);
scope.labelData = labelData;
resolve();
}
});
}
render() {
return <div ref={element => (this.element = element)} style={{ width: '100%' }} />;
}
}
@@ -0,0 +1,115 @@
import React from 'react';
import { Project } from './Project';
import StackdriverDatasource from '../datasource';
export interface Props {
datasource: StackdriverDatasource;
rawQuery: string;
lastQueryError: string;
}
interface State {
displayHelp: boolean;
displaRawQuery: boolean;
}
export class Help extends React.Component<Props, State> {
state: State = {
displayHelp: false,
displaRawQuery: false,
};
onHelpClicked = () => {
this.setState({ displayHelp: !this.state.displayHelp });
};
onRawQueryClicked = () => {
this.setState({ displaRawQuery: !this.state.displaRawQuery });
};
shouldComponentUpdate(nextProps) {
return nextProps.metricDescriptor !== null;
}
render() {
const { displayHelp, displaRawQuery } = this.state;
const { datasource, rawQuery, lastQueryError } = this.props;
return (
<>
<div className="gf-form-inline">
<Project datasource={datasource} />
<div className="gf-form" onClick={this.onHelpClicked}>
<label className="gf-form-label query-keyword pointer">
Show Help <i className={`fa fa-caret-${displayHelp ? 'down' : 'right'}`} />
</label>
</div>
{rawQuery && (
<div className="gf-form" onClick={this.onRawQueryClicked}>
<label className="gf-form-label query-keyword">
Raw Query <i className={`fa fa-caret-${displaRawQuery ? 'down' : 'right'}`} ng-show="ctrl.showHelp" />
</label>
</div>
)}
<div className="gf-form gf-form--grow">
<div className="gf-form-label gf-form-label--grow" />
</div>
</div>
{rawQuery &&
displaRawQuery && (
<div className="gf-form">
<pre className="gf-form-pre">{rawQuery}</pre>
</div>
)}
{displayHelp && (
<div className="gf-form grafana-info-box" style={{ padding: 0 }}>
<pre className="gf-form-pre alert alert-info" style={{ marginRight: 0 }}>
<h5>Alias Patterns</h5>Format the legend keys any way you want by using alias patterns. Format the legend
keys any way you want by using alias patterns.<br /> <br />
Example:
<code>{`${'{{metricDescriptor.name}} - {{metricDescriptor.label.instance_name}}'}`}</code>
<br />
Result: &nbsp;&nbsp;<code>cpu/usage_time - server1-europe-west-1</code>
<br />
<br />
<strong>Patterns</strong>
<br />
<ul>
<li>
<code>{`${'{{metricDescriptor.type}}'}`}</code> = metric type e.g.
compute.googleapis.com/instance/cpu/usage_time
</li>
<li>
<code>{`${'{{metricDescriptor.name}}'}`}</code> = name part of metric e.g. instance/cpu/usage_time
</li>
<li>
<code>{`${'{{metricDescriptor.service}}'}`}</code> = service part of metric e.g. compute
</li>
<li>
<code>{`${'{{metricDescriptor.label.label_name}}'}`}</code> = Metric label metadata e.g.
metricDescriptor.label.instance_name
</li>
<li>
<code>{`${'{{resource.label.label_name}}'}`}</code> = Resource label metadata e.g. resource.label.zone
</li>
<li>
<code>{`${'{{bucket}}'}`}</code> = bucket boundary for distribution metrics when using a heatmap in
Grafana
</li>
</ul>
</pre>
</div>
)}
{lastQueryError && (
<div className="gf-form">
<pre className="gf-form-pre alert alert-error">{lastQueryError}</pre>
</div>
)}
</>
);
}
}
@@ -0,0 +1,195 @@
import React from 'react';
import _ from 'lodash';
import StackdriverDatasource from '../datasource';
import appEvents from 'app/core/app_events';
import { MetricDescriptor } from '../types';
import { MetricSelect } from 'app/core/components/Select/MetricSelect';
import { TemplateSrv } from 'app/features/templating/template_srv';
export interface Props {
onChange: (metricDescriptor: MetricDescriptor) => void;
templateSrv: TemplateSrv;
datasource: StackdriverDatasource;
defaultProject: string;
metricType: string;
children?: (renderProps: any) => JSX.Element;
}
interface State {
metricDescriptors: MetricDescriptor[];
metrics: any[];
services: any[];
service: string;
metric: string;
metricDescriptor: MetricDescriptor;
defaultProject: string;
}
export class Metrics extends React.Component<Props, State> {
state: State = {
metricDescriptors: [],
metrics: [],
services: [],
service: '',
metric: '',
metricDescriptor: null,
defaultProject: '',
};
constructor(props) {
super(props);
}
componentDidMount() {
this.setState({ defaultProject: this.props.defaultProject }, () => {
this.getCurrentProject()
.then(this.loadMetricDescriptors.bind(this))
.then(this.initializeServiceAndMetrics.bind(this));
});
}
async getCurrentProject() {
return new Promise(async (resolve, reject) => {
try {
if (!this.state.defaultProject || this.state.defaultProject === 'loading project...') {
const defaultProject = await this.props.datasource.getDefaultProject();
this.setState({ defaultProject });
}
resolve(this.state.defaultProject);
} catch (error) {
appEvents.emit('ds-request-error', error);
reject();
}
});
}
async loadMetricDescriptors() {
if (this.state.defaultProject !== 'loading project...') {
const metricDescriptors = await this.props.datasource.getMetricTypes(this.state.defaultProject);
this.setState({ metricDescriptors });
return metricDescriptors;
} else {
return [];
}
}
async initializeServiceAndMetrics() {
const { metricDescriptors } = this.state;
const services = this.getServicesList(metricDescriptors);
const metrics = this.getMetricsList(metricDescriptors);
const service = metrics.length > 0 ? metrics[0].service : '';
const metricDescriptor = this.getSelectedMetricDescriptor(this.props.metricType);
this.setState({ metricDescriptors, services, metrics, service: service, metricDescriptor });
}
getSelectedMetricDescriptor(metricType) {
return this.state.metricDescriptors.find(md => md.type === this.props.templateSrv.replace(metricType));
}
getMetricsList(metricDescriptors: MetricDescriptor[]) {
const selectedMetricDescriptor = this.getSelectedMetricDescriptor(this.props.metricType);
if (!selectedMetricDescriptor) {
return [];
}
const metricsByService = metricDescriptors.filter(m => m.service === selectedMetricDescriptor.service).map(m => ({
service: m.service,
value: m.type,
label: m.displayName,
description: m.description,
}));
return metricsByService;
}
onServiceChange = service => {
const { metricDescriptors } = this.state;
const { templateSrv, metricType } = this.props;
const metrics = metricDescriptors.filter(m => m.service === templateSrv.replace(service)).map(m => ({
service: m.service,
value: m.type,
label: m.displayName,
description: m.description,
}));
this.setState({ service, metrics });
if (metrics.length > 0 && !metrics.some(m => m.value === templateSrv.replace(metricType))) {
this.onMetricTypeChange(metrics[0].value);
}
};
onMetricTypeChange = value => {
const metricDescriptor = this.getSelectedMetricDescriptor(value);
this.setState({ metricDescriptor });
this.props.onChange({ ...metricDescriptor, type: value });
};
getServicesList(metricDescriptors: MetricDescriptor[]) {
const services = metricDescriptors.map(m => ({
value: m.service,
label: _.startCase(m.serviceShortName),
}));
return services.length > 0 ? _.uniqBy(services, s => s.value) : [];
}
getTemplateVariablesGroup() {
return {
label: 'Template Variables',
options: this.props.templateSrv.variables.map(v => ({
label: `$${v.name}`,
value: `$${v.name}`,
})),
};
}
render() {
const { services, service, metrics } = this.state;
const { metricType, templateSrv } = this.props;
return (
<>
<div className="gf-form-inline">
<div className="gf-form">
<span className="gf-form-label width-9 query-keyword">Service</span>
<MetricSelect
onChange={this.onServiceChange}
value={service}
options={services}
isSearchable={false}
placeholder="Select Services"
className="width-15"
/>
</div>
<div className="gf-form gf-form--grow">
<div className="gf-form-label gf-form-label--grow" />
</div>
</div>
<div className="gf-form-inline">
<div className="gf-form">
<span className="gf-form-label width-9 query-keyword">Metric</span>
<MetricSelect
onChange={this.onMetricTypeChange}
value={metricType}
variables={templateSrv.variables}
options={[
{
label: 'Metrics',
expanded: true,
options: metrics,
},
]}
placeholder="Select Metric"
className="width-15"
/>
</div>
<div className="gf-form gf-form--grow">
<div className="gf-form-label gf-form-label--grow" />
</div>
</div>
{this.props.children(this.state.metricDescriptor)}
</>
);
}
}
@@ -0,0 +1,31 @@
import React from 'react';
import StackdriverDatasource from '../datasource';
export interface Props {
datasource: StackdriverDatasource;
}
interface State {
projectName: string;
}
export class Project extends React.Component<Props, State> {
state: State = {
projectName: 'Loading project...',
};
async componentDidMount() {
const projectName = await this.props.datasource.getDefaultProject();
this.setState({ projectName });
}
render() {
const { projectName } = this.state;
return (
<div className="gf-form">
<span className="gf-form-label width-9 query-keyword">Project</span>
<input className="gf-form-input width-15" disabled type="text" value={projectName} />
</div>
);
}
}
@@ -0,0 +1,23 @@
import React from 'react';
import renderer from 'react-test-renderer';
import { QueryEditor, Props, DefaultTarget } from './QueryEditor';
import { TemplateSrv } from 'app/features/templating/template_srv';
const props: Props = {
onQueryChange: target => {},
onExecuteQuery: () => {},
target: DefaultTarget,
events: { on: () => {} },
datasource: {
getDefaultProject: () => Promise.resolve('project'),
getMetricTypes: () => Promise.resolve([]),
} as any,
templateSrv: new TemplateSrv(),
};
describe('QueryEditor', () => {
it('renders correctly', () => {
const tree = renderer.create(<QueryEditor {...props} />).toJSON();
expect(tree).toMatchSnapshot();
});
});
@@ -0,0 +1,206 @@
import React from 'react';
import _ from 'lodash';
import { TemplateSrv } from 'app/features/templating/template_srv';
import { Metrics } from './Metrics';
import { Filter } from './Filter';
import { Aggregations } from './Aggregations';
import { Alignments } from './Alignments';
import { AlignmentPeriods } from './AlignmentPeriods';
import { AliasBy } from './AliasBy';
import { Help } from './Help';
import { Target, MetricDescriptor } from '../types';
import { getAlignmentPickerData } from '../functions';
import StackdriverDatasource from '../datasource';
import { SelectOptionItem } from '@grafana/ui';
export interface Props {
onQueryChange: (target: Target) => void;
onExecuteQuery: () => void;
target: Target;
events: any;
datasource: StackdriverDatasource;
templateSrv: TemplateSrv;
}
interface State extends Target {
alignOptions: SelectOptionItem[];
lastQuery: string;
lastQueryError: string;
[key: string]: any;
}
export const DefaultTarget: State = {
defaultProject: 'loading project...',
metricType: '',
metricKind: '',
valueType: '',
refId: '',
service: '',
unit: '',
crossSeriesReducer: 'REDUCE_MEAN',
alignmentPeriod: 'stackdriver-auto',
perSeriesAligner: 'ALIGN_MEAN',
groupBys: [],
filters: [],
aliasBy: '',
alignOptions: [],
lastQuery: '',
lastQueryError: '',
usedAlignmentPeriod: '',
};
export class QueryEditor extends React.Component<Props, State> {
state: State = DefaultTarget;
componentDidMount() {
const { events, target, templateSrv } = this.props;
events.on('data-received', this.onDataReceived.bind(this));
events.on('data-error', this.onDataError.bind(this));
const { perSeriesAligner, alignOptions } = getAlignmentPickerData(target, templateSrv);
this.setState({
...this.props.target,
alignOptions,
perSeriesAligner,
});
}
componentWillUnmount() {
this.props.events.off('data-received', this.onDataReceived);
this.props.events.off('data-error', this.onDataError);
}
onDataReceived(dataList) {
const series = dataList.find(item => item.refId === this.props.target.refId);
if (series) {
this.setState({
lastQuery: decodeURIComponent(series.meta.rawQuery),
lastQueryError: '',
usedAlignmentPeriod: series.meta.alignmentPeriod,
});
}
}
onDataError(err) {
let lastQuery;
let lastQueryError;
if (err.data && err.data.error) {
lastQueryError = this.props.datasource.formatStackdriverError(err);
} else if (err.data && err.data.results) {
const queryRes = err.data.results[this.props.target.refId];
lastQuery = decodeURIComponent(queryRes.meta.rawQuery);
if (queryRes && queryRes.error) {
try {
lastQueryError = JSON.parse(queryRes.error).error.message;
} catch {
lastQueryError = queryRes.error;
}
}
}
this.setState({ lastQuery, lastQueryError });
}
onMetricTypeChange = ({ valueType, metricKind, type, unit }: MetricDescriptor) => {
const { templateSrv, onQueryChange, onExecuteQuery } = this.props;
const { perSeriesAligner, alignOptions } = getAlignmentPickerData(
{ valueType, metricKind, perSeriesAligner: this.state.perSeriesAligner },
templateSrv
);
this.setState(
{
alignOptions,
perSeriesAligner,
metricType: type,
unit,
valueType,
metricKind,
},
() => {
onQueryChange(this.state);
onExecuteQuery();
}
);
};
onPropertyChange(prop, value) {
this.setState({ [prop]: value }, () => {
this.props.onQueryChange(this.state);
this.props.onExecuteQuery();
});
}
render() {
const {
usedAlignmentPeriod,
defaultProject,
metricType,
crossSeriesReducer,
groupBys,
filters,
perSeriesAligner,
alignOptions,
alignmentPeriod,
aliasBy,
lastQuery,
lastQueryError,
refId,
} = this.state;
const { datasource, templateSrv } = this.props;
return (
<>
<Metrics
defaultProject={defaultProject}
metricType={metricType}
templateSrv={templateSrv}
datasource={datasource}
onChange={this.onMetricTypeChange}
>
{metric => (
<>
<Filter
filtersChanged={value => this.onPropertyChange('filters', value)}
groupBysChanged={value => this.onPropertyChange('groupBys', value)}
filters={filters}
groupBys={groupBys}
refId={refId}
hideGroupBys={false}
templateSrv={templateSrv}
datasource={datasource}
metricType={metric ? metric.type : ''}
/>
<Aggregations
metricDescriptor={metric}
templateSrv={templateSrv}
crossSeriesReducer={crossSeriesReducer}
groupBys={groupBys}
onChange={value => this.onPropertyChange('crossSeriesReducer', value)}
>
{displayAdvancedOptions =>
displayAdvancedOptions && (
<Alignments
alignOptions={alignOptions}
templateSrv={templateSrv}
perSeriesAligner={perSeriesAligner}
onChange={value => this.onPropertyChange('perSeriesAligner', value)}
/>
)
}
</Aggregations>
<AlignmentPeriods
templateSrv={templateSrv}
alignmentPeriod={alignmentPeriod}
perSeriesAligner={perSeriesAligner}
usedAlignmentPeriod={usedAlignmentPeriod}
onChange={value => this.onPropertyChange('alignmentPeriod', value)}
/>
<AliasBy value={aliasBy} onChange={value => this.onPropertyChange('aliasBy', value)} />
<Help datasource={datasource} rawQuery={lastQuery} lastQueryError={lastQueryError} />
</>
)}
</Metrics>
</>
);
}
}
@@ -63,7 +63,7 @@ export class StackdriverVariableQueryEditor extends PureComponent<VariableQueryP
this.setState(state);
}
async handleQueryTypeChange(event) {
async onQueryTypeChange(event) {
const state: any = {
selectedQueryType: event.target.value,
...await this.getLabels(this.state.selectedMetricType, event.target.value),
@@ -134,7 +134,7 @@ export class StackdriverVariableQueryEditor extends PureComponent<VariableQueryP
case MetricFindQueryTypes.LabelValues:
case MetricFindQueryTypes.ResourceTypes:
return (
<React.Fragment>
<>
<SimpleSelect
value={this.state.selectedService}
options={this.insertTemplateVariables(this.state.services)}
@@ -155,12 +155,12 @@ export class StackdriverVariableQueryEditor extends PureComponent<VariableQueryP
label="Label Key"
/>
)}
</React.Fragment>
</>
);
case MetricFindQueryTypes.Aligners:
case MetricFindQueryTypes.Aggregations:
return (
<React.Fragment>
<>
<SimpleSelect
value={this.state.selectedService}
options={this.insertTemplateVariables(this.state.services)}
@@ -173,7 +173,7 @@ export class StackdriverVariableQueryEditor extends PureComponent<VariableQueryP
onValueChange={e => this.onMetricTypeChange(e)}
label="Metric Type"
/>
</React.Fragment>
</>
);
default:
return '';
@@ -182,15 +182,15 @@ export class StackdriverVariableQueryEditor extends PureComponent<VariableQueryP
render() {
return (
<React.Fragment>
<>
<SimpleSelect
value={this.state.selectedQueryType}
options={this.queryTypes}
onValueChange={e => this.handleQueryTypeChange(e)}
onValueChange={e => this.onQueryTypeChange(e)}
label="Query Type"
/>
{this.renderQueryTypeSwitch(this.state.selectedQueryType)}
</React.Fragment>
</>
);
}
}
@@ -0,0 +1,119 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Aggregations renders correctly 1`] = `
Array [
<div
className="gf-form-inline"
>
<div
className="gf-form"
>
<label
className="gf-form-label query-keyword width-9"
>
Aggregation
</label>
<div
className="css-0 gf-form-input gf-form-input--form-dropdown width-15"
onKeyDown={[Function]}
>
<div
className="css-0 gf-form-select-box__control"
onMouseDown={[Function]}
onTouchEnd={[Function]}
>
<div
className="css-0 gf-form-select-box__value-container"
>
<div
className="css-0 gf-form-select-box__placeholder"
>
Select Aggregation
</div>
<div
className="css-0"
>
<div
className="gf-form-select-box__input"
style={
Object {
"display": "inline-block",
}
}
>
<input
aria-autocomplete="list"
autoCapitalize="none"
autoComplete="off"
autoCorrect="off"
disabled={false}
id="react-select-2-input"
onBlur={[Function]}
onChange={[Function]}
onFocus={[Function]}
spellCheck="false"
style={
Object {
"background": 0,
"border": 0,
"boxSizing": "content-box",
"color": "inherit",
"fontSize": "inherit",
"opacity": 1,
"outline": 0,
"padding": 0,
"width": "1px",
}
}
tabIndex="0"
type="text"
value=""
/>
<div
style={
Object {
"height": 0,
"left": 0,
"overflow": "scroll",
"position": "absolute",
"top": 0,
"visibility": "hidden",
"whiteSpace": "pre",
}
}
>
</div>
</div>
</div>
</div>
<div
className="css-0 gf-form-select-box__indicators"
>
<span
className="gf-form-select-box__select-arrow "
/>
</div>
</div>
</div>
</div>
<div
className="gf-form gf-form--grow"
>
<label
className="gf-form-label gf-form-label--grow"
>
<a
onClick={[Function]}
>
<i
className="fa fa-caret-right"
/>
Advanced Options
</a>
</label>
</div>
</div>,
<div />,
]
`;
@@ -0,0 +1,459 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`QueryEditor renders correctly 1`] = `
Array [
<div
className="gf-form-inline"
>
<div
className="gf-form"
>
<span
className="gf-form-label width-9 query-keyword"
>
Service
</span>
<div
className="css-0 gf-form-input gf-form-input--form-dropdown width-15"
onKeyDown={[Function]}
>
<div
className="css-0 gf-form-select-box__control"
onMouseDown={[Function]}
onTouchEnd={[Function]}
>
<div
className="css-0 gf-form-select-box__value-container"
>
<div
className="css-0 gf-form-select-box__placeholder"
>
Select Services
</div>
<input
className="css-14uuagi"
disabled={false}
id="react-select-2-input"
onBlur={[Function]}
onChange={[Function]}
onFocus={[Function]}
readOnly={true}
tabIndex="0"
value=""
/>
</div>
<div
className="css-0 gf-form-select-box__indicators"
>
<span
className="gf-form-select-box__select-arrow "
/>
</div>
</div>
</div>
</div>
<div
className="gf-form gf-form--grow"
>
<div
className="gf-form-label gf-form-label--grow"
/>
</div>
</div>,
<div
className="gf-form-inline"
>
<div
className="gf-form"
>
<span
className="gf-form-label width-9 query-keyword"
>
Metric
</span>
<div
className="css-0 gf-form-input gf-form-input--form-dropdown width-15"
onKeyDown={[Function]}
>
<div
className="css-0 gf-form-select-box__control"
onMouseDown={[Function]}
onTouchEnd={[Function]}
>
<div
className="css-0 gf-form-select-box__value-container"
>
<div
className="css-0 gf-form-select-box__placeholder"
>
Select Metric
</div>
<div
className="css-0"
>
<div
className="gf-form-select-box__input"
style={
Object {
"display": "inline-block",
}
}
>
<input
aria-autocomplete="list"
autoCapitalize="none"
autoComplete="off"
autoCorrect="off"
disabled={false}
id="react-select-3-input"
onBlur={[Function]}
onChange={[Function]}
onFocus={[Function]}
spellCheck="false"
style={
Object {
"background": 0,
"border": 0,
"boxSizing": "content-box",
"color": "inherit",
"fontSize": "inherit",
"opacity": 1,
"outline": 0,
"padding": 0,
"width": "1px",
}
}
tabIndex="0"
type="text"
value=""
/>
<div
style={
Object {
"height": 0,
"left": 0,
"overflow": "scroll",
"position": "absolute",
"top": 0,
"visibility": "hidden",
"whiteSpace": "pre",
}
}
>
</div>
</div>
</div>
</div>
<div
className="css-0 gf-form-select-box__indicators"
>
<span
className="gf-form-select-box__select-arrow "
/>
</div>
</div>
</div>
</div>
<div
className="gf-form gf-form--grow"
>
<div
className="gf-form-label gf-form-label--grow"
/>
</div>
</div>,
<div
style={
Object {
"width": "100%",
}
}
/>,
<div
className="gf-form-inline"
>
<div
className="gf-form"
>
<label
className="gf-form-label query-keyword width-9"
>
Aggregation
</label>
<div
className="css-0 gf-form-input gf-form-input--form-dropdown width-15"
onKeyDown={[Function]}
>
<div
className="css-0 gf-form-select-box__control"
onMouseDown={[Function]}
onTouchEnd={[Function]}
>
<div
className="css-0 gf-form-select-box__value-container"
>
<div
className="css-0 gf-form-select-box__placeholder"
>
Select Aggregation
</div>
<div
className="css-0"
>
<div
className="gf-form-select-box__input"
style={
Object {
"display": "inline-block",
}
}
>
<input
aria-autocomplete="list"
autoCapitalize="none"
autoComplete="off"
autoCorrect="off"
disabled={false}
id="react-select-4-input"
onBlur={[Function]}
onChange={[Function]}
onFocus={[Function]}
spellCheck="false"
style={
Object {
"background": 0,
"border": 0,
"boxSizing": "content-box",
"color": "inherit",
"fontSize": "inherit",
"opacity": 1,
"outline": 0,
"padding": 0,
"width": "1px",
}
}
tabIndex="0"
type="text"
value=""
/>
<div
style={
Object {
"height": 0,
"left": 0,
"overflow": "scroll",
"position": "absolute",
"top": 0,
"visibility": "hidden",
"whiteSpace": "pre",
}
}
>
</div>
</div>
</div>
</div>
<div
className="css-0 gf-form-select-box__indicators"
>
<span
className="gf-form-select-box__select-arrow "
/>
</div>
</div>
</div>
</div>
<div
className="gf-form gf-form--grow"
>
<label
className="gf-form-label gf-form-label--grow"
>
<a
onClick={[Function]}
>
<i
className="fa fa-caret-right"
/>
Advanced Options
</a>
</label>
</div>
</div>,
<div
className="gf-form-inline"
>
<div
className="gf-form"
>
<label
className="gf-form-label query-keyword width-9"
>
Alignment Period
</label>
<div
className="css-0 gf-form-input gf-form-input--form-dropdown width-15"
onKeyDown={[Function]}
>
<div
className="css-0 gf-form-select-box__control"
onMouseDown={[Function]}
onTouchEnd={[Function]}
>
<div
className="css-0 gf-form-select-box__value-container gf-form-select-box__value-container--has-value"
>
<div
className="css-0 gf-form-select-box__single-value"
>
<div
className="gf-form-select-box__img-value"
>
stackdriver auto
</div>
</div>
<div
className="css-0"
>
<div
className="gf-form-select-box__input"
style={
Object {
"display": "inline-block",
}
}
>
<input
aria-autocomplete="list"
autoCapitalize="none"
autoComplete="off"
autoCorrect="off"
disabled={false}
id="react-select-5-input"
onBlur={[Function]}
onChange={[Function]}
onFocus={[Function]}
spellCheck="false"
style={
Object {
"background": 0,
"border": 0,
"boxSizing": "content-box",
"color": "inherit",
"fontSize": "inherit",
"opacity": 1,
"outline": 0,
"padding": 0,
"width": "1px",
}
}
tabIndex="0"
type="text"
value=""
/>
<div
style={
Object {
"height": 0,
"left": 0,
"overflow": "scroll",
"position": "absolute",
"top": 0,
"visibility": "hidden",
"whiteSpace": "pre",
}
}
>
</div>
</div>
</div>
</div>
<div
className="css-0 gf-form-select-box__indicators"
>
<span
className="gf-form-select-box__select-arrow "
/>
</div>
</div>
</div>
</div>
<div
className="gf-form gf-form--grow"
>
</div>
</div>,
<div
className="gf-form-inline"
>
<div
className="gf-form"
>
<label
className="gf-form-label query-keyword width-9"
>
Alias By
</label>
<input
className="gf-form-input width-24"
onChange={[Function]}
type="text"
value=""
/>
</div>
<div
className="gf-form gf-form--grow"
>
<div
className="gf-form-label gf-form-label--grow"
/>
</div>
</div>,
<div
className="gf-form-inline"
>
<div
className="gf-form"
>
<span
className="gf-form-label width-9 query-keyword"
>
Project
</span>
<input
className="gf-form-input width-15"
disabled={true}
type="text"
value="Loading project..."
/>
</div>
<div
className="gf-form"
onClick={[Function]}
>
<label
className="gf-form-label query-keyword pointer"
>
Show Help
<i
className="fa fa-caret-right"
/>
</label>
</div>
<div
className="gf-form gf-form--grow"
>
<div
className="gf-form-label gf-form-label--grow"
/>
</div>
</div>,
"",
"",
]
`;
@@ -2,6 +2,7 @@ import { stackdriverUnitMappings } from './constants';
import appEvents from 'app/core/app_events';
import _ from 'lodash';
import StackdriverMetricFindQuery from './StackdriverMetricFindQuery';
import { MetricDescriptor } from './types';
export default class StackdriverDatasource {
id: number;
@@ -28,21 +29,15 @@ export default class StackdriverDatasource {
return !target.hide && target.metricType;
})
.map(t => {
if (!t.hasOwnProperty('aggregation')) {
t.aggregation = {
crossSeriesReducer: 'REDUCE_MEAN',
groupBys: [],
};
}
return {
refId: t.refId,
intervalMs: options.intervalMs,
datasourceId: this.id,
metricType: this.templateSrv.replace(t.metricType, options.scopedVars || {}),
primaryAggregation: this.templateSrv.replace(t.aggregation.crossSeriesReducer, options.scopedVars || {}),
perSeriesAligner: this.templateSrv.replace(t.aggregation.perSeriesAligner, options.scopedVars || {}),
alignmentPeriod: this.templateSrv.replace(t.aggregation.alignmentPeriod, options.scopedVars || {}),
groupBys: this.interpolateGroupBys(t.aggregation.groupBys, options.scopedVars),
primaryAggregation: this.templateSrv.replace(t.crossSeriesReducer || 'REDUCE_MEAN', options.scopedVars || {}),
perSeriesAligner: this.templateSrv.replace(t.perSeriesAligner, options.scopedVars || {}),
alignmentPeriod: this.templateSrv.replace(t.alignmentPeriod, options.scopedVars || {}),
groupBys: this.interpolateGroupBys(t.groupBys, options.scopedVars),
view: t.view || 'FULL',
filters: (t.filters || []).map(f => {
return this.templateSrv.replace(f, options.scopedVars || {});
@@ -75,9 +70,7 @@ export default class StackdriverDatasource {
refId: refId,
datasourceId: this.id,
metricType: this.templateSrv.replace(metricType),
aggregation: {
crossSeriesReducer: 'REDUCE_NONE',
},
crossSeriesReducer: 'REDUCE_NONE',
view: 'HEADERS',
},
],
@@ -261,7 +254,7 @@ export default class StackdriverDatasource {
}
}
async getMetricTypes(projectName: string) {
async getMetricTypes(projectName: string): Promise<MetricDescriptor[]> {
try {
if (this.metricTypes.length === 0) {
const metricsApiPath = `v3/projects/${projectName}/metricDescriptors`;
@@ -273,6 +266,7 @@ export default class StackdriverDatasource {
m.service = service;
m.serviceShortName = serviceShortName;
m.displayName = m.displayName || m.type;
return m;
});
}
@@ -5,13 +5,13 @@ export class FilterSegments {
filterSegments: any[];
removeSegment: any;
constructor(private uiSegmentSrv, private target, private getFilterKeysFunc, private getFilterValuesFunc) {}
constructor(private uiSegmentSrv, private filters, private getFilterKeysFunc, private getFilterValuesFunc) {}
buildSegmentModel() {
this.removeSegment = this.uiSegmentSrv.newSegment({ fake: true, value: DefaultRemoveFilterValue });
this.filterSegments = [];
this.target.filters.forEach((f, index) => {
this.filters.forEach((f, index) => {
switch (index % 4) {
case 0:
this.filterSegments.push(this.uiSegmentSrv.newKey(f));
@@ -0,0 +1,38 @@
import { getAlignmentOptionsByMetric } from './functions';
import { ValueTypes, MetricKind } from './constants';
describe('functions', () => {
let result;
describe('getAlignmentOptionsByMetric', () => {
describe('when double and gauge is passed', () => {
beforeEach(() => {
result = getAlignmentOptionsByMetric(ValueTypes.DOUBLE, MetricKind.GAUGE);
});
it('should return all alignment options except two', () => {
expect(result.length).toBe(9);
expect(result.map(o => o.value)).toEqual(
expect.not.arrayContaining(['REDUCE_COUNT_TRUE', 'REDUCE_COUNT_FALSE'])
);
});
});
describe('when double and delta is passed', () => {
beforeEach(() => {
result = getAlignmentOptionsByMetric(ValueTypes.DOUBLE, MetricKind.DELTA);
});
it('should return all alignment options except four', () => {
expect(result.length).toBe(9);
expect(result.map(o => o.value)).toEqual(
expect.not.arrayContaining([
'ALIGN_COUNT_TRUE',
'ALIGN_COUNT_FALSE',
'ALIGN_FRACTION_TRUE',
'ALIGN_INTERPOLATE',
])
);
});
});
});
});
@@ -46,3 +46,21 @@ export const getLabelKeys = async (datasource, selectedMetricType) => {
: [];
return labelKeys;
};
export const getAlignmentPickerData = ({ valueType, metricKind, perSeriesAligner }, templateSrv) => {
const options = getAlignmentOptionsByMetric(valueType, metricKind).map(option => ({
...option,
label: option.text,
}));
const alignOptions = [
{
label: 'Alignment options',
expanded: true,
options,
},
];
if (!options.some(o => o.value === templateSrv.replace(perSeriesAligner))) {
perSeriesAligner = options.length > 0 ? options[0].value : '';
}
return { alignOptions, perSeriesAligner };
};
@@ -1,37 +1,6 @@
<stackdriver-filter target="ctrl.annotation.target" refresh="ctrl.refresh()" datasource="ctrl.datasource"
default-dropdown-value="ctrl.defaultDropdownValue" default-service-value="ctrl.defaultServiceValue" hide-group-bys="true"></stackdriver-filter>
<div class="gf-form gf-form-inline">
<div class="gf-form">
<span class="gf-form-label query-keyword width-9">Title</span>
<input type="text" class="gf-form-input width-20" ng-model="ctrl.annotation.target.title" />
</div>
<div class="gf-form">
<span class="gf-form-label query-keyword width-9">Text</span>
<input type="text" class="gf-form-input width-20" ng-model="ctrl.annotation.target.text" />
</div>
<div class="gf-form gf-form--grow">
<div class="gf-form-label gf-form-label--grow"></div>
</div>
</div>
<div class="gf-form grafana-info-box" style="padding: 0">
<pre class="gf-form-pre alert alert-info" style="margin-right: 0"><h5>Annotation Query Format</h5>
An annotation is an event that is overlaid on top of graphs. Annotation rendering is expensive so it is important to limit the number of rows returned.
The Title and Text fields support templating and can use data returned from the query. For example, the Title field could have the following text:
<code ng-non-bindable>{{metric.type}} has value: {{metric.value}}</code>
Example Result: <code ng-non-bindable>monitoring.googleapis.com/uptime_check/http_status has this value: 502</code>
<label>Patterns:</label>
<code ng-non-bindable>{{metric.value}}</code> = value of the metric/point
<code ng-non-bindable>{{metric.type}}</code> = metric type e.g. compute.googleapis.com/instance/cpu/usage_time
<code ng-non-bindable>{{metric.name}}</code> = name part of metric e.g. instance/cpu/usage_time
<code ng-non-bindable>{{metric.service}}</code> = service part of metric e.g. compute
<code ng-non-bindable>{{metric.label.label_name}}</code> = Metric label metadata e.g. metric.label.instance_name
<code ng-non-bindable>{{resource.label.label_name}}</code> = Resource label metadata e.g. resource.label.zone
</pre>
</div>
<stackdriver-annotation-query-editor
target="ctrl.annotation.target"
on-query-change="(ctrl.onQueryChange)"
datasource="ctrl.datasource"
template-srv="ctrl.templateSrv"
></stackdriver-annotation-query-editor>
@@ -1,46 +0,0 @@
<div class="gf-form-inline">
<div class="gf-form">
<label class="gf-form-label query-keyword width-9">Aggregation</label>
<div class="gf-form-select-wrapper gf-form-select-wrapper--caret-indent">
<gf-form-dropdown model="ctrl.target.aggregation.crossSeriesReducer" get-options="ctrl.aggOptions" class="gf-form width-12"
disabled type="text" allow-custom="true" lookup-text="true" css-class="min-width-12" on-change="refresh()"></gf-form-dropdown>
</div>
</div>
<div class="gf-form gf-form--grow">
<label class="gf-form-label gf-form-label--grow">
<a ng-click="ctrl.target.showAggregationOptions = !ctrl.target.showAggregationOptions">
<i class="fa fa-caret-down" ng-show="ctrl.target.showAggregationOptions"></i>
<i class="fa fa-caret-right" ng-hide="ctrl.target.showAggregationOptions"></i>
Advanced Options
</a>
</label>
</div>
</div>
<div class="gf-form-group" ng-if="ctrl.target.showAggregationOptions">
<div class="gf-form offset-width-9">
<label class="gf-form-label query-keyword width-12">Aligner</label>
<div class="gf-form-select-wrapper gf-form-select-wrapper--caret-indent">
<gf-form-dropdown model="ctrl.target.aggregation.perSeriesAligner" get-options="ctrl.alignOptions" class="gf-form width-12"
disabled type="text" allow-custom="true" lookup-text="true" css-class="min-width-12" on-change="refresh()"></gf-form-dropdown>
</div>
<div class="gf-form gf-form--grow">
<div class="gf-form-label gf-form-label--grow"></div>
</div>
</div>
</div>
<div class="gf-form-inline">
<div class="gf-form">
<label class="gf-form-label query-keyword width-9">Alignment Period</label>
<div class="gf-form-select-wrapper gf-form-select-wrapper--caret-indent">
<gf-form-dropdown model="ctrl.target.aggregation.alignmentPeriod" get-options="ctrl.alignmentPeriods" class="gf-form width-12"
disabled type="text" allow-custom="true" lookup-text="true" css-class="min-width-12" on-change="refresh()"></gf-form-dropdown>
</div>
</div>
<div class="gf-form gf-form--grow">
<label ng-if="alignmentPeriod" class="gf-form-label gf-form-label--grow">
{{ctrl.formatAlignmentText()}}
</label>
</div>
</div>
@@ -1,73 +1,10 @@
<query-editor-row query-ctrl="ctrl" has-text-edit-mode="false">
<stackdriver-filter target="ctrl.target" refresh="ctrl.refresh()" datasource="ctrl.datasource" default-dropdown-value="ctrl.defaultDropdownValue"
default-service-value="ctrl.defaultServiceValue"></stackdriver-filter>
<stackdriver-aggregation target="ctrl.target" alignment-period="ctrl.lastQueryMeta.alignmentPeriod" refresh="ctrl.refresh()"></stackdriver-aggregation>
<div class="gf-form-inline">
<div class="gf-form">
<span class="gf-form-label query-keyword width-9">Alias By</span>
<input type="text" class="gf-form-input width-30" ng-model="ctrl.target.aliasBy" ng-change="ctrl.refresh()"
ng-model-options="{ debounce: 500 }" />
</div>
<div class="gf-form gf-form--grow">
<div class="gf-form-label gf-form-label--grow"></div>
</div>
</div>
<div class="gf-form-inline">
<div class="gf-form">
<span class="gf-form-label width-9 query-keyword">Project</span>
<input class="gf-form-input" disabled type="text" ng-model='ctrl.target.defaultProject' css-class="min-width-12" />
</div>
<div class="gf-form">
<label class="gf-form-label query-keyword" ng-click="ctrl.showHelp = !ctrl.showHelp">
Show Help
<i class="fa fa-caret-down" ng-show="ctrl.showHelp"></i>
<i class="fa fa-caret-right" ng-hide="ctrl.showHelp"></i>
</label>
</div>
<div class="gf-form" ng-show="ctrl.lastQueryMeta">
<label class="gf-form-label query-keyword" ng-click="ctrl.showLastQuery = !ctrl.showLastQuery">
Raw Query
<i class="fa fa-caret-down" ng-show="ctrl.showLastQuery"></i>
<i class="fa fa-caret-right" ng-hide="ctrl.showLastQuery"></i>
</label>
</div>
<div class="gf-form gf-form--grow">
<div class="gf-form-label gf-form-label--grow"></div>
</div>
</div>
<div class="gf-form" ng-show="ctrl.showLastQuery">
<pre class="gf-form-pre">{{ctrl.lastQueryMeta.rawQueryString}}</pre>
</div>
<div class="gf-form grafana-info-box" style="padding: 0" ng-show="ctrl.showHelp">
<pre class="gf-form-pre alert alert-info" style="margin-right: 0"><h5>Alias Patterns</h5>Format the legend keys any way you want by using alias patterns.
Format the legend keys any way you want by using alias patterns.<br /> <br />
Example: <code ng-non-bindable>{{metric.name}} - {{metric.label.instance_name}}</code><br />
Result: &nbsp;&nbsp;<code ng-non-bindable>cpu/usage_time - server1-europe-west-1</code><br /><br />
<strong>Patterns</strong><br />
<ul>
<li>
<code ng-non-bindable>{{metric.type}}</code> = metric type e.g. compute.googleapis.com/instance/cpu/usage_time
</li>
<li>
<code ng-non-bindable>{{metric.name}}</code> = name part of metric e.g. instance/cpu/usage_time
</li>
<li>
<code ng-non-bindable>{{metric.service}}</code> = service part of metric e.g. compute
</li>
<li>
<code ng-non-bindable>{{metric.label.label_name}}</code> = Metric label metadata e.g.
metric.label.instance_name
</li>
<li>
<code ng-non-bindable>{{resource.label.label_name}}</code> = Resource label metadata e.g. resource.label.zone
</li>
</ul>
</div>
<div class="gf-form" ng-show="ctrl.lastQueryError">
<pre class="gf-form-pre alert alert-error">{{ctrl.lastQueryError}}</pre>
</div>
</query-editor-row>
<stackdriver-query-editor
target="ctrl.target"
events="ctrl.panelCtrl.events"
datasource="ctrl.datasource"
template-srv="ctrl.templateSrv"
on-query-change="(ctrl.onQueryChange)"
on-execute-query="(ctrl.onExecuteQuery)"
></stackdriver-query-editor>
</query-editor-row>
@@ -1,29 +1,3 @@
<div class="gf-form-inline">
<div class="gf-form">
<span class="gf-form-label width-9 query-keyword">Service</span>
<select
class="gf-form-input width-12"
ng-model="ctrl.service"
ng-options="f.value as f.text for f in ctrl.services"
ng-change="ctrl.onServiceChange(ctrl.service)"
></select>
</div>
<div class="gf-form">
<span class="gf-form-label width-9 query-keyword">Metric</span>
<gf-form-dropdown
model="ctrl.metricType"
get-options="ctrl.metrics"
class="min-width-20"
disabled
type="text"
allow-custom="true"
lookup-text="true"
css-class="min-width-12"
on-change="ctrl.onMetricTypeChange()"
></gf-form-dropdown>
</div>
<div class="gf-form gf-form--grow"><div class="gf-form-label gf-form-label--grow"></div></div>
</div>
<div class="gf-form-inline">
<div class="gf-form">
<span class="gf-form-label query-keyword width-9">Filter</span>
@@ -37,7 +11,7 @@
</div>
<div class="gf-form gf-form--grow"><div class="gf-form-label gf-form-label--grow"></div></div>
</div>
<div class="gf-form-inline" ng-hide="ctrl.$scope.hideGroupBys">
<div class="gf-form-inline" ng-hide="ctrl.hideGroupBys">
<div class="gf-form">
<span class="gf-form-label query-keyword width-9">Group By</span>
<div class="gf-form" ng-repeat="segment in ctrl.groupBySegments">
@@ -1,80 +0,0 @@
import coreModule from 'app/core/core_module';
import _ from 'lodash';
import * as options from './constants';
import { getAlignmentOptionsByMetric, getAggregationOptionsByMetric } from './functions';
import kbn from 'app/core/utils/kbn';
export class StackdriverAggregation {
constructor() {
return {
templateUrl: 'public/app/plugins/datasource/stackdriver/partials/query.aggregation.html',
controller: 'StackdriverAggregationCtrl',
restrict: 'E',
scope: {
target: '=',
alignmentPeriod: '<',
refresh: '&',
},
};
}
}
export class StackdriverAggregationCtrl {
alignmentPeriods: any[];
aggOptions: any[];
alignOptions: any[];
target: any;
/** @ngInject */
constructor(private $scope, private templateSrv) {
this.$scope.ctrl = this;
this.target = $scope.target;
this.alignmentPeriods = options.alignmentPeriods;
this.aggOptions = options.aggOptions;
this.alignOptions = options.alignOptions;
this.setAggOptions();
this.setAlignOptions();
const self = this;
$scope.$on('metricTypeChanged', () => {
self.setAggOptions();
self.setAlignOptions();
});
}
setAlignOptions() {
this.alignOptions = getAlignmentOptionsByMetric(this.target.valueType, this.target.metricKind);
if (!this.alignOptions.find(o => o.value === this.templateSrv.replace(this.target.aggregation.perSeriesAligner))) {
this.target.aggregation.perSeriesAligner = this.alignOptions.length > 0 ? this.alignOptions[0].value : '';
}
}
setAggOptions() {
this.aggOptions = getAggregationOptionsByMetric(this.target.valueType, this.target.metricKind);
if (!this.aggOptions.find(o => o.value === this.templateSrv.replace(this.target.aggregation.crossSeriesReducer))) {
this.deselectAggregationOption('REDUCE_NONE');
}
if (this.target.aggregation.groupBys.length > 0) {
this.aggOptions = this.aggOptions.filter(o => o.value !== 'REDUCE_NONE');
this.deselectAggregationOption('REDUCE_NONE');
}
}
formatAlignmentText() {
const selectedAlignment = this.alignOptions.find(
ap => ap.value === this.templateSrv.replace(this.target.aggregation.perSeriesAligner)
);
return `${kbn.secondsToHms(this.$scope.alignmentPeriod)} interval (${
selectedAlignment ? selectedAlignment.text : ''
})`;
}
deselectAggregationOption(notValidOptionValue: string) {
const newValue = this.aggOptions.find(o => o.value !== notValidOptionValue);
this.target.aggregation.crossSeriesReducer = newValue ? newValue.value : '';
}
}
coreModule.directive('stackdriverAggregation', StackdriverAggregation);
coreModule.controller('StackdriverAggregationCtrl', StackdriverAggregationCtrl);
@@ -1,97 +1,26 @@
import _ from 'lodash';
import { QueryCtrl } from 'app/plugins/sdk';
import './query_aggregation_ctrl';
import './query_filter_ctrl';
export interface QueryMeta {
alignmentPeriod: string;
rawQuery: string;
rawQueryString: string;
metricLabels: { [key: string]: string[] };
resourceLabels: { [key: string]: string[] };
}
import { QueryCtrl } from 'app/plugins/sdk';
import { Target } from './types';
import { TemplateSrv } from 'app/features/templating/template_srv';
export class StackdriverQueryCtrl extends QueryCtrl {
static templateUrl = 'partials/query.editor.html';
target: {
defaultProject: string;
unit: string;
metricType: string;
service: string;
refId: string;
aggregation: {
crossSeriesReducer: string;
alignmentPeriod: string;
perSeriesAligner: string;
groupBys: string[];
};
filters: string[];
aliasBy: string;
metricKind: any;
valueType: any;
};
defaultDropdownValue = 'Select Metric';
defaultServiceValue = 'All Services';
defaults = {
defaultProject: 'loading project...',
metricType: this.defaultDropdownValue,
service: this.defaultServiceValue,
metric: '',
unit: '',
aggregation: {
crossSeriesReducer: 'REDUCE_MEAN',
alignmentPeriod: 'stackdriver-auto',
perSeriesAligner: 'ALIGN_MEAN',
groupBys: [],
},
filters: [],
showAggregationOptions: false,
aliasBy: '',
metricKind: '',
valueType: '',
};
showHelp: boolean;
showLastQuery: boolean;
lastQueryMeta: QueryMeta;
lastQueryError?: string;
templateSrv: TemplateSrv;
/** @ngInject */
constructor($scope, $injector) {
constructor($scope, $injector, templateSrv) {
super($scope, $injector);
_.defaultsDeep(this.target, this.defaults);
this.panelCtrl.events.on('data-received', this.onDataReceived.bind(this), $scope);
this.panelCtrl.events.on('data-error', this.onDataError.bind(this), $scope);
this.templateSrv = templateSrv;
this.onQueryChange = this.onQueryChange.bind(this);
this.onExecuteQuery = this.onExecuteQuery.bind(this);
}
onDataReceived(dataList) {
this.lastQueryError = null;
this.lastQueryMeta = null;
const anySeriesFromQuery: any = _.find(dataList, { refId: this.target.refId });
if (anySeriesFromQuery) {
this.lastQueryMeta = anySeriesFromQuery.meta;
this.lastQueryMeta.rawQueryString = decodeURIComponent(this.lastQueryMeta.rawQuery);
}
onQueryChange(target: Target) {
Object.assign(this.target, target);
}
onDataError(err) {
if (err.data && err.data.results) {
const queryRes = err.data.results[this.target.refId];
if (queryRes && queryRes.error) {
this.lastQueryMeta = queryRes.meta;
this.lastQueryMeta.rawQueryString = decodeURIComponent(this.lastQueryMeta.rawQuery);
let jsonBody;
try {
jsonBody = JSON.parse(queryRes.error);
} catch {
}
this.lastQueryError = jsonBody.error.message;
}
}
onExecuteQuery() {
this.$scope.ctrl.refresh();
}
}
@@ -1,7 +1,6 @@
import coreModule from 'app/core/core_module';
import _ from 'lodash';
import { FilterSegments } from './filter_segments';
import appEvents from 'app/core/app_events';
import { FilterSegments, DefaultFilterValue } from './filter_segments';
export class StackdriverFilter {
/** @ngInject */
@@ -10,13 +9,15 @@ export class StackdriverFilter {
templateUrl: 'public/app/plugins/datasource/stackdriver/partials/query.filter.html',
controller: 'StackdriverFilterCtrl',
controllerAs: 'ctrl',
bindToController: true,
restrict: 'E',
scope: {
target: '=',
datasource: '=',
refresh: '&',
defaultDropdownValue: '<',
defaultServiceValue: '<',
labelData: '<',
loading: '<',
groupBys: '<',
filters: '<',
filtersChanged: '&',
groupBysChanged: '&',
hideGroupBys: '<',
},
};
@@ -24,46 +25,28 @@ export class StackdriverFilter {
}
export class StackdriverFilterCtrl {
metricLabels: { [key: string]: string[] };
resourceLabels: { [key: string]: string[] };
resourceTypes: string[];
defaultRemoveGroupByValue = '-- remove group by --';
resourceTypeValue = 'resource.type';
loadLabelsPromise: Promise<any>;
service: string;
metricType: string;
metricDescriptors: any[];
metrics: any[];
services: any[];
groupBySegments: any[];
filterSegments: FilterSegments;
removeSegment: any;
target: any;
datasource: any;
filters: string[];
groupBys: string[];
hideGroupBys: boolean;
labelData: any;
loading: Promise<any>;
filtersChanged: (filters) => void;
groupBysChanged: (groupBys) => void;
/** @ngInject */
constructor(private $scope, private uiSegmentSrv, private templateSrv, private $rootScope) {
this.datasource = $scope.datasource;
this.target = $scope.target;
this.metricType = $scope.defaultDropdownValue;
this.service = $scope.defaultServiceValue;
this.metricDescriptors = [];
this.metrics = [];
this.services = [];
this.getCurrentProject()
.then(this.loadMetricDescriptors.bind(this))
.then(this.getLabels.bind(this));
this.initSegments($scope.hideGroupBys);
constructor(private $scope, private uiSegmentSrv, private templateSrv) {
this.$scope.ctrl = this;
this.initSegments(this.hideGroupBys);
}
initSegments(hideGroupBys: boolean) {
if (!hideGroupBys) {
this.groupBySegments = this.target.aggregation.groupBys.map(groupBy => {
this.groupBySegments = this.groupBys.map(groupBy => {
return this.uiSegmentSrv.getSegmentForValue(groupBy);
});
this.ensurePlusButton(this.groupBySegments);
@@ -73,133 +56,17 @@ export class StackdriverFilterCtrl {
this.filterSegments = new FilterSegments(
this.uiSegmentSrv,
this.target,
this.filters,
this.getFilterKeys.bind(this),
this.getFilterValues.bind(this)
);
this.filterSegments.buildSegmentModel();
}
async getCurrentProject() {
return new Promise(async (resolve, reject) => {
try {
if (!this.target.defaultProject || this.target.defaultProject === 'loading project...') {
this.target.defaultProject = await this.datasource.getDefaultProject();
}
resolve(this.target.defaultProject);
} catch (error) {
appEvents.emit('ds-request-error', error);
reject();
}
});
}
async loadMetricDescriptors() {
if (this.target.defaultProject !== 'loading project...') {
this.metricDescriptors = await this.datasource.getMetricTypes(this.target.defaultProject);
this.services = this.getServicesList();
this.metrics = this.getMetricsList();
return this.metricDescriptors;
} else {
return [];
}
}
getServicesList() {
const defaultValue = { value: this.$scope.defaultServiceValue, text: this.$scope.defaultServiceValue };
const services = this.metricDescriptors.map(m => {
return {
value: m.service,
text: m.serviceShortName,
};
});
if (services.find(m => m.value === this.target.service)) {
this.service = this.target.service;
}
return services.length > 0 ? [defaultValue, ..._.uniqBy(services, 'value')] : [];
}
getMetricsList() {
const metrics = this.metricDescriptors.map(m => {
return {
service: m.service,
value: m.type,
serviceShortName: m.serviceShortName,
text: m.displayName,
title: m.description,
};
});
let result;
if (this.target.service === this.$scope.defaultServiceValue) {
result = metrics.map(m => ({ ...m, text: `${m.service} - ${m.text}` }));
} else {
result = metrics.filter(m => m.service === this.target.service);
}
if (result.find(m => m.value === this.templateSrv.replace(this.target.metricType))) {
this.metricType = this.target.metricType;
} else if (result.length > 0) {
this.metricType = this.target.metricType = result[0].value;
}
return result;
}
async getLabels() {
this.loadLabelsPromise = new Promise(async resolve => {
try {
const { meta } = await this.datasource.getLabels(this.target.metricType, this.target.refId);
this.metricLabels = meta.metricLabels;
this.resourceLabels = meta.resourceLabels;
this.resourceTypes = meta.resourceTypes;
resolve();
} catch (error) {
if (error.data && error.data.message) {
console.log(error.data.message);
} else {
console.log(error);
}
appEvents.emit('alert-error', ['Error', 'Error loading metric labels for ' + this.target.metricType]);
resolve();
}
});
}
onServiceChange() {
this.target.service = this.service;
this.metrics = this.getMetricsList();
this.setMetricType();
this.getLabels();
if (!this.metrics.find(m => m.value === this.target.metricType)) {
this.target.metricType = this.$scope.defaultDropdownValue;
} else {
this.$scope.refresh();
}
}
async onMetricTypeChange() {
this.setMetricType();
this.$scope.refresh();
this.getLabels();
}
setMetricType() {
this.target.metricType = this.metricType;
const { valueType, metricKind, unit } = this.metricDescriptors.find(
m => m.type === this.templateSrv.replace(this.metricType)
);
this.target.unit = unit;
this.target.valueType = valueType;
this.target.metricKind = metricKind;
this.$rootScope.$broadcast('metricTypeChanged');
}
async createLabelKeyElements() {
await this.loadLabelsPromise;
await this.loading;
let elements = Object.keys(this.metricLabels || {}).map(l => {
let elements = Object.keys(this.labelData.metricLabels || {}).map(l => {
return this.uiSegmentSrv.newSegment({
value: `metric.label.${l}`,
expandable: false,
@@ -208,7 +75,7 @@ export class StackdriverFilterCtrl {
elements = [
...elements,
...Object.keys(this.resourceLabels || {}).map(l => {
...Object.keys(this.labelData.resourceLabels || {}).map(l => {
return this.uiSegmentSrv.newSegment({
value: `resource.label.${l}`,
expandable: false,
@@ -216,7 +83,7 @@ export class StackdriverFilterCtrl {
}),
];
if (this.resourceTypes && this.resourceTypes.length > 0) {
if (this.labelData.resourceTypes && this.labelData.resourceTypes.length > 0) {
elements = [
...elements,
this.uiSegmentSrv.newSegment({
@@ -229,10 +96,10 @@ export class StackdriverFilterCtrl {
return elements;
}
async getFilterKeys(segment, removeText?: string) {
async getFilterKeys(segment, removeText: string) {
let elements = await this.createLabelKeyElements();
if (this.target.filters.indexOf(this.resourceTypeValue) !== -1) {
if (this.filters.indexOf(this.resourceTypeValue) !== -1) {
elements = elements.filter(e => e.value !== this.resourceTypeValue);
}
@@ -241,21 +108,24 @@ export class StackdriverFilterCtrl {
return [];
}
this.removeSegment.value = removeText;
return [...elements, this.removeSegment];
return segment.type === 'plus-button'
? elements
: [
...elements,
this.uiSegmentSrv.newSegment({ fake: true, value: removeText || this.defaultRemoveGroupByValue }),
];
}
async getGroupBys(segment) {
let elements = await this.createLabelKeyElements();
elements = elements.filter(e => this.target.aggregation.groupBys.indexOf(e.value) === -1);
elements = elements.filter(e => this.groupBys.indexOf(e.value) === -1);
const noValueOrPlusButton = !segment || segment.type === 'plus-button';
if (noValueOrPlusButton && elements.length === 0) {
return [];
}
this.removeSegment.value = this.defaultRemoveGroupByValue;
return [...elements, this.removeSegment];
return segment.type === 'plus-button' ? elements : [...elements, this.removeSegment];
}
groupByChanged(segment, index) {
@@ -272,43 +142,45 @@ export class StackdriverFilterCtrl {
return memo;
};
this.target.aggregation.groupBys = this.groupBySegments.reduce(reducer, []);
const groupBys = this.groupBySegments.reduce(reducer, []);
this.groupBysChanged({ groupBys });
this.ensurePlusButton(this.groupBySegments);
this.$rootScope.$broadcast('metricTypeChanged');
this.$scope.refresh();
}
async getFilters(segment, index) {
const hasNoFilterKeys = this.metricLabels && Object.keys(this.metricLabels).length === 0;
await this.loading;
const hasNoFilterKeys = this.labelData.metricLabels && Object.keys(this.labelData.metricLabels).length === 0;
return this.filterSegments.getFilters(segment, index, hasNoFilterKeys);
}
getFilterValues(index) {
const filterKey = this.templateSrv.replace(this.filterSegments.filterSegments[index - 2].value);
if (!filterKey || !this.metricLabels || Object.keys(this.metricLabels).length === 0) {
if (!filterKey || !this.labelData.metricLabels || Object.keys(this.labelData.metricLabels).length === 0) {
return [];
}
const shortKey = filterKey.substring(filterKey.indexOf('.label.') + 7);
if (filterKey.startsWith('metric.label.') && this.metricLabels.hasOwnProperty(shortKey)) {
return this.metricLabels[shortKey];
if (filterKey.startsWith('metric.label.') && this.labelData.metricLabels.hasOwnProperty(shortKey)) {
return this.labelData.metricLabels[shortKey];
}
if (filterKey.startsWith('resource.label.') && this.resourceLabels.hasOwnProperty(shortKey)) {
return this.resourceLabels[shortKey];
if (filterKey.startsWith('resource.label.') && this.labelData.resourceLabels.hasOwnProperty(shortKey)) {
return this.labelData.resourceLabels[shortKey];
}
if (filterKey === this.resourceTypeValue) {
return this.resourceTypes;
return this.labelData.resourceTypes;
}
return [];
}
filterSegmentUpdated(segment, index) {
this.target.filters = this.filterSegments.filterSegmentUpdated(segment, index);
this.$scope.refresh();
const filters = this.filterSegments.filterSegmentUpdated(segment, index);
if (!filters.some(f => f === DefaultFilterValue)) {
this.filtersChanged({ filters });
}
}
ensurePlusButton(segments) {
@@ -82,7 +82,6 @@ describe('StackdriverDataSource', () => {
targets: [
{
refId: 'A',
aggregation: {},
},
],
};
@@ -1,74 +0,0 @@
import { StackdriverAggregationCtrl } from '../query_aggregation_ctrl';
describe('StackdriverAggregationCtrl', () => {
let ctrl;
describe('aggregation and alignment options', () => {
describe('when new query result is returned from the server', () => {
describe('and result is double and gauge and no group by is used', () => {
beforeEach(async () => {
ctrl = new StackdriverAggregationCtrl(
{
$on: () => {},
target: {
valueType: 'DOUBLE',
metricKind: 'GAUGE',
aggregation: { crossSeriesReducer: '', groupBys: [] },
},
},
{
replace: s => s,
}
);
});
it('should populate all aggregate options except two', () => {
ctrl.setAggOptions();
expect(ctrl.aggOptions.length).toBe(11);
expect(ctrl.aggOptions.map(o => o.value)).toEqual(
expect['not'].arrayContaining(['REDUCE_COUNT_TRUE', 'REDUCE_COUNT_FALSE'])
);
});
it('should populate all alignment options except two', () => {
ctrl.setAlignOptions();
expect(ctrl.alignOptions.length).toBe(9);
expect(ctrl.alignOptions.map(o => o.value)).toEqual(
expect['not'].arrayContaining(['REDUCE_COUNT_TRUE', 'REDUCE_COUNT_FALSE'])
);
});
});
describe('and result is double and gauge and a group by is used', () => {
beforeEach(async () => {
ctrl = new StackdriverAggregationCtrl(
{
$on: () => {},
target: {
valueType: 'DOUBLE',
metricKind: 'GAUGE',
aggregation: { crossSeriesReducer: 'REDUCE_NONE', groupBys: ['resource.label.projectid'] },
},
},
{
replace: s => s,
}
);
});
it('should populate all aggregate options except three', () => {
ctrl.setAggOptions();
expect(ctrl.aggOptions.length).toBe(10);
expect(ctrl.aggOptions.map(o => o.value)).toEqual(
expect['not'].arrayContaining(['REDUCE_COUNT_TRUE', 'REDUCE_COUNT_FALSE', 'REDUCE_NONE'])
);
});
it('should select some other reducer than REDUCE_NONE', () => {
ctrl.setAggOptions();
expect(ctrl.target.aggregation.crossSeriesReducer).not.toBe('');
expect(ctrl.target.aggregation.crossSeriesReducer).not.toBe('REDUCE_NONE');
});
});
});
});
});
@@ -5,6 +5,7 @@ import { DefaultRemoveFilterValue, DefaultFilterValue } from '../filter_segments
describe('StackdriverQueryFilterCtrl', () => {
let ctrl;
let result;
let groupByChangedMock;
describe('when initializing query editor', () => {
beforeEach(() => {
@@ -32,10 +33,10 @@ describe('StackdriverQueryFilterCtrl', () => {
describe('when labels are fetched', () => {
beforeEach(async () => {
ctrl.metricLabels = { 'metric-key-1': ['metric-value-1'] };
ctrl.resourceLabels = { 'resource-key-1': ['resource-value-1'] };
ctrl.labelData.metricLabels = { 'metric-key-1': ['metric-value-1'] };
ctrl.labelData.resourceLabels = { 'resource-key-1': ['resource-value-1'] };
result = await ctrl.getGroupBys();
result = await ctrl.getGroupBys({ type: '' });
});
it('should populate group bys segments', () => {
@@ -48,17 +49,17 @@ describe('StackdriverQueryFilterCtrl', () => {
describe('when a group by label is selected', () => {
beforeEach(async () => {
ctrl.metricLabels = {
ctrl.labelData.metricLabels = {
'metric-key-1': ['metric-value-1'],
'metric-key-2': ['metric-value-2'],
};
ctrl.resourceLabels = {
ctrl.labelData.resourceLabels = {
'resource-key-1': ['resource-value-1'],
'resource-key-2': ['resource-value-2'],
};
ctrl.target.aggregation.groupBys = ['metric.label.metric-key-1', 'resource.label.resource-key-1'];
ctrl.groupBys = ['metric.label.metric-key-1', 'resource.label.resource-key-1'];
result = await ctrl.getGroupBys();
result = await ctrl.getGroupBys({ type: '' });
});
it('should not be used to populate group bys segments', () => {
@@ -71,6 +72,8 @@ describe('StackdriverQueryFilterCtrl', () => {
describe('when a group by is selected', () => {
beforeEach(() => {
groupByChangedMock = jest.fn();
ctrl.groupBysChanged = groupByChangedMock;
const removeSegment = { fake: true, value: '-- remove group by --' };
const segment = { value: 'groupby1' };
ctrl.groupBySegments = [segment, removeSegment];
@@ -78,12 +81,14 @@ describe('StackdriverQueryFilterCtrl', () => {
});
it('should be added to group bys list', () => {
expect(ctrl.target.aggregation.groupBys.length).toBe(1);
expect(groupByChangedMock).toHaveBeenCalledWith({ groupBys: ['groupby1'] });
});
});
describe('when a selected group by is removed', () => {
beforeEach(() => {
groupByChangedMock = jest.fn();
ctrl.groupBysChanged = groupByChangedMock;
const removeSegment = { fake: true, value: '-- remove group by --' };
const segment = { value: 'groupby1' };
ctrl.groupBySegments = [segment, removeSegment];
@@ -91,7 +96,7 @@ describe('StackdriverQueryFilterCtrl', () => {
});
it('should be added to group bys list', () => {
expect(ctrl.target.aggregation.groupBys.length).toBe(0);
expect(groupByChangedMock).toHaveBeenCalledWith({ groupBys: [] });
});
});
});
@@ -130,11 +135,11 @@ describe('StackdriverQueryFilterCtrl', () => {
describe('when values for a key filter part are fetched', () => {
beforeEach(async () => {
ctrl.metricLabels = {
ctrl.labelData.metricLabels = {
'metric-key-1': ['metric-value-1'],
'metric-key-2': ['metric-value-2'],
};
ctrl.resourceLabels = {
ctrl.labelData.resourceLabels = {
'resource-key-1': ['resource-value-1'],
'resource-key-2': ['resource-value-2'],
};
@@ -155,11 +160,11 @@ describe('StackdriverQueryFilterCtrl', () => {
describe('when values for a value filter part are fetched', () => {
beforeEach(async () => {
ctrl.metricLabels = {
ctrl.labelData.metricLabels = {
'metric-key-1': ['metric-value-1'],
'metric-key-2': ['metric-value-2'],
};
ctrl.resourceLabels = {
ctrl.labelData.resourceLabels = {
'resource-key-1': ['resource-value-1'],
'resource-key-2': ['resource-value-2'],
};
@@ -198,6 +203,7 @@ describe('StackdriverQueryFilterCtrl', () => {
});
});
});
describe('when has one existing filter', () => {
describe('and user clicks on key segment', () => {
beforeEach(() => {
@@ -213,7 +219,6 @@ describe('StackdriverQueryFilterCtrl', () => {
];
ctrl.filterSegmentUpdated(existingKeySegment, 0);
});
it('should not add any new segments', () => {
expect(ctrl.filterSegments.filterSegments.length).toBe(4);
expect(ctrl.filterSegments.filterSegments[0].type).toBe('key');
@@ -229,7 +234,6 @@ describe('StackdriverQueryFilterCtrl', () => {
ctrl.filterSegments.filterSegments = [existingKeySegment, existingOperatorSegment, existingValueSegment];
ctrl.filterSegmentUpdated(existingValueSegment, 2);
});
it('should ensure that plus segment exists', () => {
expect(ctrl.filterSegments.filterSegments.length).toBe(4);
expect(ctrl.filterSegments.filterSegments[0].type).toBe('key');
@@ -238,7 +242,6 @@ describe('StackdriverQueryFilterCtrl', () => {
expect(ctrl.filterSegments.filterSegments[3].type).toBe('plus-button');
});
});
describe('and user clicks on value segment and value is equal to fake value', () => {
beforeEach(() => {
const existingKeySegment = { value: 'filterkey1', type: 'key' };
@@ -247,7 +250,6 @@ describe('StackdriverQueryFilterCtrl', () => {
ctrl.filterSegments.filterSegments = [existingKeySegment, existingOperatorSegment, existingValueSegment];
ctrl.filterSegmentUpdated(existingValueSegment, 2);
});
it('should not add plus segment', () => {
expect(ctrl.filterSegments.filterSegments.length).toBe(3);
expect(ctrl.filterSegments.filterSegments[0].type).toBe('key');
@@ -269,13 +271,11 @@ describe('StackdriverQueryFilterCtrl', () => {
];
ctrl.filterSegmentUpdated(existingKeySegment, 0);
});
it('should remove filter segments', () => {
expect(ctrl.filterSegments.filterSegments.length).toBe(1);
expect(ctrl.filterSegments.filterSegments[0].type).toBe('plus-button');
});
});
describe('and user removes key segment and there is a previous filter', () => {
beforeEach(() => {
const existingKeySegment1 = { value: DefaultRemoveFilterValue, type: 'key' };
@@ -296,7 +296,6 @@ describe('StackdriverQueryFilterCtrl', () => {
];
ctrl.filterSegmentUpdated(existingKeySegment2, 4);
});
it('should remove filter segments and the condition segment', () => {
expect(ctrl.filterSegments.filterSegments.length).toBe(4);
expect(ctrl.filterSegments.filterSegments[0].type).toBe('key');
@@ -305,7 +304,6 @@ describe('StackdriverQueryFilterCtrl', () => {
expect(ctrl.filterSegments.filterSegments[3].type).toBe('plus-button');
});
});
describe('and user removes key segment and there is a filter after it', () => {
beforeEach(() => {
const existingKeySegment1 = { value: DefaultRemoveFilterValue, type: 'key' };
@@ -326,7 +324,6 @@ describe('StackdriverQueryFilterCtrl', () => {
];
ctrl.filterSegmentUpdated(existingKeySegment1, 0);
});
it('should remove filter segments and the condition segment', () => {
expect(ctrl.filterSegments.filterSegments.length).toBe(4);
expect(ctrl.filterSegments.filterSegments[0].type).toBe('key');
@@ -335,7 +332,6 @@ describe('StackdriverQueryFilterCtrl', () => {
expect(ctrl.filterSegments.filterSegments[3].type).toBe('plus-button');
});
});
describe('and user clicks on plus button', () => {
beforeEach(() => {
const existingKeySegment = { value: 'filterkey1', type: 'key' };
@@ -350,7 +346,6 @@ describe('StackdriverQueryFilterCtrl', () => {
];
ctrl.filterSegmentUpdated(plusSegment, 3);
});
it('should condition segment and new filter segments', () => {
expect(ctrl.filterSegments.filterSegments.length).toBe(7);
expect(ctrl.filterSegments.filterSegments[0].type).toBe('key');
@@ -367,13 +362,6 @@ describe('StackdriverQueryFilterCtrl', () => {
});
function createCtrlWithFakes(existingFilters?: string[]) {
StackdriverFilterCtrl.prototype.loadMetricDescriptors = () => {
return Promise.resolve([]);
};
StackdriverFilterCtrl.prototype.getLabels = () => {
return Promise.resolve();
};
const fakeSegmentServer = {
newKey: val => {
return { value: val, type: 'key' };
@@ -403,40 +391,24 @@ function createCtrlWithFakes(existingFilters?: string[]) {
},
};
const scope = {
target: createTarget(existingFilters),
hideGroupBys: false,
groupBys: [],
filters: existingFilters || [],
labelData: {
metricLabels: {},
resourceLabels: {},
resourceTypes: [],
},
filtersChanged: () => {},
groupBysChanged: () => {},
datasource: {
getDefaultProject: () => {
return 'project';
},
},
defaultDropdownValue: 'Select Metric',
defaultServiceValue: 'All Services',
refresh: () => {},
};
return new StackdriverFilterCtrl(scope, fakeSegmentServer, new TemplateSrvStub(), { $broadcast: param => {} });
}
function createTarget(existingFilters?: string[]) {
return {
project: {
id: '',
name: '',
},
unit: '',
metricType: 'ametric',
service: '',
refId: 'A',
aggregation: {
crossSeriesReducer: '',
alignmentPeriod: '',
perSeriesAligner: '',
groupBys: [],
},
filters: existingFilters || [],
aliasBy: '',
metricService: '',
metricKind: '',
valueType: '',
};
Object.assign(StackdriverFilterCtrl.prototype, scope);
return new StackdriverFilterCtrl(scope, fakeSegmentServer, new TemplateSrvStub());
}
@@ -19,3 +19,50 @@ export interface VariableQueryData {
metricTypes: Array<{ value: string; name: string }>;
services: Array<{ value: string; name: string }>;
}
export interface Target {
defaultProject: string;
unit: string;
metricType: string;
service: string;
refId: string;
crossSeriesReducer: string;
alignmentPeriod: string;
perSeriesAligner: string;
groupBys: string[];
filters: string[];
aliasBy: string;
metricKind: string;
valueType: string;
}
export interface AnnotationTarget {
defaultProject: string;
metricType: string;
refId: string;
filters: string[];
metricKind: string;
valueType: string;
title: string;
text: string;
}
export interface QueryMeta {
alignmentPeriod: string;
rawQuery: string;
rawQueryString: string;
metricLabels: { [key: string]: string[] };
resourceLabels: { [key: string]: string[] };
resourceTypes: string[];
}
export interface MetricDescriptor {
valueType: string;
metricKind: string;
type: string;
unit: string;
service: string;
serviceShortName: string;
displayName: string;
description: string;
}
+5
View File
@@ -0,0 +1,5 @@
export interface Variable {
name: string;
type: string;
current: any;
}
-2
View File
@@ -3,7 +3,6 @@
// VENDOR
@import '../vendor/css/timepicker.css';
@import '../vendor/css/spectrum.css';
@import '../vendor/css/rc-cascader.scss';
// MIXINS
@@ -76,7 +75,6 @@
@import 'components/typeahead';
@import 'components/modals';
@import 'components/dropdown';
@import 'components/color_picker';
@import 'components/footer';
@import 'components/infobox';
@import 'components/shortcuts';
View File
+1032 -3063
View File
File diff suppressed because it is too large Load Diff