diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 148722e5f73..fe0a1d6c548 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -12,7 +12,7 @@ grunt karma:dev ### Run tests for backend assets before commit ``` -test -z "$(gofmt -s -l . | grep -v vendor/src/ | tee /dev/stderr)" +test -z "$(gofmt -s -l . | grep -v -E 'vendor/(github.com|golang.org|gopkg.in)' | tee /dev/stderr)" ``` ### Run tests for frontend assets before commit diff --git a/CHANGELOG.md b/CHANGELOG.md index 505a5f433bb..92f4ddfc586 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ * **Graphite**: Add support for groupByNode, closes [#5613](https://github.com/grafana/grafana/pull/5613) * **Influxdb**: Add support for elapsed(), closes [#5827](https://github.com/grafana/grafana/pull/5827) * **OAuth**: Add support for generic oauth, closes [#4718](https://github.com/grafana/grafana/pull/4718) +* **Cloudwatch**: Add support to expand multi select template variable, closes [#5003](https://github.com/grafana/grafana/pull/5003) ### Breaking changes * **SystemD**: Change systemd description, closes [#5971](https://github.com/grafana/grafana/pull/5971) diff --git a/Gruntfile.js b/Gruntfile.js index 9a0c69b96f9..1f96048746b 100644 --- a/Gruntfile.js +++ b/Gruntfile.js @@ -9,7 +9,6 @@ module.exports = function (grunt) { genDir: 'public_gen', destDir: 'dist', tempDir: 'tmp', - arch: os.arch(), platform: process.platform.replace('win32', 'windows'), }; @@ -17,6 +16,10 @@ module.exports = function (grunt) { config.arch = process.env.hasOwnProperty('ProgramFiles(x86)') ? 'x64' : 'x86'; } + config.arch = grunt.option('arch') || os.arch(); + + config.phjs = grunt.option('phjsToRelease'); + config.pkg.version = grunt.option('pkgVer') || config.pkg.version; console.log('Version', config.pkg.version); diff --git a/README.md b/README.md index 3a5c7dce6d3..98f4b4d3c9d 100644 --- a/README.md +++ b/README.md @@ -96,7 +96,7 @@ easily the grafana repository you want to build. ```bash go get github.com/*your_account*/grafana mkdir $GOPATH/src/github.com/grafana -ln -s github.com/*your_account*/grafana $GOPATH/src/github.com/grafana/grafana +ln -s $GOPATH/src/github.com/*your_account*/grafana $GOPATH/src/github.com/grafana/grafana ``` ### Building the backend diff --git a/build.go b/build.go index b881645b5fb..f9ef09ff5b9 100644 --- a/build.go +++ b/build.go @@ -25,11 +25,16 @@ var ( versionRe = regexp.MustCompile(`-[0-9]{1,3}-g[0-9a-f]{5,10}`) goarch string goos string + gocc string + gocxx string + cgo string + pkgArch string version string = "v1" // deb & rpm does not support semver so have to handle their version a little differently linuxPackageVersion string = "v1" linuxPackageIteration string = "" race bool + phjsToRelease string workingDir string binaries []string = []string{"grafana-server", "grafana-cli"} ) @@ -47,6 +52,11 @@ func main() { flag.StringVar(&goarch, "goarch", runtime.GOARCH, "GOARCH") flag.StringVar(&goos, "goos", runtime.GOOS, "GOOS") + flag.StringVar(&gocc, "cc", "", "CC") + flag.StringVar(&gocxx, "cxx", "", "CXX") + flag.StringVar(&cgo, "cgo-enabled", "", "CGO_ENABLED") + flag.StringVar(&pkgArch, "pkg-arch", "", "PKG ARCH") + flag.StringVar(&phjsToRelease, "phjs", "", "PhantomJS binary") flag.BoolVar(&race, "race", race, "Use race detector") flag.Parse() @@ -73,15 +83,15 @@ func main() { grunt("test") case "package": - grunt("release", fmt.Sprintf("--pkgVer=%v-%v", linuxPackageVersion, linuxPackageIteration)) + grunt(gruntBuildArg("release")...) createLinuxPackages() case "pkg-rpm": - grunt("release") + grunt(gruntBuildArg("release")...) createRpmPackages() case "pkg-deb": - grunt("release") + grunt(gruntBuildArg("release")...) createDebPackages() case "latest": @@ -258,6 +268,10 @@ func createPackage(options linuxPackageOptions) { "-p", "./dist", } + if pkgArch != "" { + args = append(args, "-a", pkgArch) + } + if linuxPackageIteration != "" { args = append(args, "--iteration", linuxPackageIteration) } @@ -307,9 +321,20 @@ func grunt(params ...string) { runPrint("./node_modules/.bin/grunt", params...) } +func gruntBuildArg(task string) []string { + args := []string{task, fmt.Sprintf("--pkgVer=%v-%v", linuxPackageVersion, linuxPackageIteration)} + if pkgArch != "" { + args = append(args, fmt.Sprintf("--arch=%v", pkgArch)) + } + if phjsToRelease != "" { + args = append(args, fmt.Sprintf("--phjsToRelease=%v", phjsToRelease)) + } + return args +} + func setup() { runPrint("go", "get", "-v", "github.com/kardianos/govendor") - runPrint("go", "get", "-v", "github.com/blang/semver") + runPrint("go", "get", "-v", "github.com/blang/semver") runPrint("go", "get", "-v", "github.com/mattn/go-sqlite3") runPrint("go", "install", "-v", "github.com/mattn/go-sqlite3") } @@ -382,6 +407,15 @@ func setBuildEnv() { if goarch == "386" { os.Setenv("GO386", "387") } + if cgo != "" { + os.Setenv("CGO_ENABLED", cgo) + } + if gocc != "" { + os.Setenv("CC", gocc) + } + if gocxx != "" { + os.Setenv("CXX", gocxx) + } } func getGitSha() string { diff --git a/conf/defaults.ini b/conf/defaults.ini index 92a0cf10b8c..3596d41bb14 100644 --- a/conf/defaults.ini +++ b/conf/defaults.ini @@ -413,7 +413,7 @@ url = https://grafana.net #################################### External Image Storage ############## [external_image_storage] -# You can choose between (s3, webdav or internal) +# You can choose between (s3, webdav) provider = s3 [external_image_storage.s3] diff --git a/pkg/api/login_oauth.go b/pkg/api/login_oauth.go index 07cf70a96e3..bc222361b25 100644 --- a/pkg/api/login_oauth.go +++ b/pkg/api/login_oauth.go @@ -3,7 +3,6 @@ package api import ( "errors" "fmt" - "net/url" "golang.org/x/oauth2" @@ -46,9 +45,9 @@ func OAuthLogin(ctx *middleware.Context) { userInfo, err := connect.UserInfo(token) if err != nil { if err == social.ErrMissingTeamMembership { - ctx.Redirect(setting.AppSubUrl + "/login?failedMsg=" + url.QueryEscape("Required team membership not fulfilled")) + ctx.Redirect(setting.AppSubUrl + "/login?failCode=1000") } else if err == social.ErrMissingOrganizationMembership { - ctx.Redirect(setting.AppSubUrl + "/login?failedMsg=" + url.QueryEscape("Required organization membership not fulfilled")) + ctx.Redirect(setting.AppSubUrl + "/login?failCode=1001") } else { ctx.Handle(500, fmt.Sprintf("login.OAuthLogin(get info from %s)", name), err) } @@ -60,7 +59,7 @@ func OAuthLogin(ctx *middleware.Context) { // validate that the email is allowed to login to grafana if !connect.IsEmailAllowed(userInfo.Email) { ctx.Logger.Info("OAuth login attempt with unallowed email", "email", userInfo.Email) - ctx.Redirect(setting.AppSubUrl + "/login?failedMsg=" + url.QueryEscape("Required email domain not fulfilled")) + ctx.Redirect(setting.AppSubUrl + "/login?failCode=1002") return } diff --git a/pkg/cmd/grafana-cli/services/services.go b/pkg/cmd/grafana-cli/services/services.go index ed1a69d50a8..e2ccce8418f 100644 --- a/pkg/cmd/grafana-cli/services/services.go +++ b/pkg/cmd/grafana-cli/services/services.go @@ -141,8 +141,6 @@ func createRequest(repoUrl string, subPaths ...string) ([]byte, error) { req, err := http.NewRequest(http.MethodGet, u.String(), nil) - logger.Info("grafanaVersion ", grafanaVersion) - req.Header.Set("grafana-version", grafanaVersion) req.Header.Set("User-Agent", "grafana "+grafanaVersion) diff --git a/public/app/core/controllers/login_ctrl.js b/public/app/core/controllers/login_ctrl.js index ee5a1832420..debdacb5704 100644 --- a/public/app/core/controllers/login_ctrl.js +++ b/public/app/core/controllers/login_ctrl.js @@ -6,6 +6,12 @@ define([ function (angular, coreModule, config) { 'use strict'; + var failCodes = { + "1000": "Required Github team membership not fulfilled", + "1001": "Required Github organization membership not fulfilled", + "1002": "Required email domain not fulfilled", + }; + coreModule.default.controller('LoginCtrl', function($scope, backendSrv, contextSrv, $location) { $scope.formModel = { user: '', @@ -37,8 +43,8 @@ function (angular, coreModule, config) { $scope.$watch("loginMode", $scope.loginModeChanged); var params = $location.search(); - if (params.failedMsg) { - $scope.appEvent('alert-warning', ['Login Failed', params.failedMsg]); + if (params.failCode) { + $scope.appEvent('alert-warning', ['Login Failed', failCodes[params.failCode]]); delete params.failedMsg; $location.search(params); } diff --git a/public/app/core/services/context_srv.ts b/public/app/core/services/context_srv.ts index 41d99c3198f..ac00528db20 100644 --- a/public/app/core/services/context_srv.ts +++ b/public/app/core/services/context_srv.ts @@ -9,6 +9,7 @@ export class User { isGrafanaAdmin: any; isSignedIn: any; orgRole: any; + timezone: string; constructor() { if (config.bootData.user) { diff --git a/public/app/core/utils/kbn.js b/public/app/core/utils/kbn.js index c0d162b0ece..cf80d671d71 100644 --- a/public/app/core/utils/kbn.js +++ b/public/app/core/utils/kbn.js @@ -9,6 +9,10 @@ function($, _, moment) { var kbn = {}; kbn.valueFormats = {}; + kbn.regexEscape = function(value) { + return value.replace(/[\\^$*+?.()|[\]{}\/]/g, '\\$&'); + }; + ///// HELPER FUNCTIONS ///// kbn.round_interval = function(interval) { diff --git a/public/app/features/all.js b/public/app/features/all.js index 43d9f33f190..cd7adb49de6 100644 --- a/public/app/features/all.js +++ b/public/app/features/all.js @@ -2,7 +2,7 @@ define([ './panellinks/module', './dashlinks/module', './annotations/annotations_srv', - './templating/templateSrv', + './templating/all', './dashboard/all', './playlist/all', './snapshot/all', diff --git a/public/app/features/annotations/annotations_srv.ts b/public/app/features/annotations/annotations_srv.ts index c40a704c2a5..060c6af50a5 100644 --- a/public/app/features/annotations/annotations_srv.ts +++ b/public/app/features/annotations/annotations_srv.ts @@ -29,7 +29,7 @@ export class AnnotationsSrv { this.getGlobalAnnotations(options), this.getPanelAnnotations(options) ]).then(allResults => { - return _.flatten(allResults); + return _.flattenDeep(allResults); }).catch(err => { this.$rootScope.appEvent('alert-error', ['Annotations failed', (err.message || err)]); }); diff --git a/public/app/features/dashboard/ad_hoc_filters.ts b/public/app/features/dashboard/ad_hoc_filters.ts new file mode 100644 index 00000000000..f962f8ca2f4 --- /dev/null +++ b/public/app/features/dashboard/ad_hoc_filters.ts @@ -0,0 +1,171 @@ +/// + +import _ from 'lodash'; +import angular from 'angular'; +import coreModule from 'app/core/core_module'; + +export class AdHocFiltersCtrl { + segments: any; + variable: any; + removeTagFilterSegment: any; + + /** @ngInject */ + constructor(private uiSegmentSrv, private datasourceSrv, private $q, private templateSrv, private $rootScope) { + this.removeTagFilterSegment = uiSegmentSrv.newSegment({fake: true, value: '-- remove filter --'}); + this.buildSegmentModel(); + } + + buildSegmentModel() { + this.segments = []; + + if (this.variable.value && !_.isArray(this.variable.value)) { + } + + for (let tag of this.variable.filters) { + if (this.segments.length > 0) { + this.segments.push(this.uiSegmentSrv.newCondition('AND')); + } + + if (tag.key !== undefined && tag.value !== undefined) { + this.segments.push(this.uiSegmentSrv.newKey(tag.key)); + this.segments.push(this.uiSegmentSrv.newOperator(tag.operator)); + this.segments.push(this.uiSegmentSrv.newKeyValue(tag.value)); + } + } + + this.segments.push(this.uiSegmentSrv.newPlusButton()); + } + + getOptions(segment, index) { + if (segment.type === 'operator') { + return this.$q.when(this.uiSegmentSrv.newOperators(['=', '!=', '<', '>', '=~', '!~'])); + } + + if (segment.type === 'condition') { + return this.$q.when([this.uiSegmentSrv.newSegment('AND')]); + } + + return this.datasourceSrv.get(this.variable.datasource).then(ds => { + var options: any = {}; + var promise = null; + + if (segment.type !== 'value') { + promise = ds.getTagKeys(); + } else { + options.key = this.segments[index-2].value; + promise = ds.getTagValues(options); + } + + return promise.then(results => { + results = _.map(results, segment => { + return this.uiSegmentSrv.newSegment({value: segment.text}); + }); + + // add remove option for keys + if (segment.type === 'key') { + results.splice(0, 0, angular.copy(this.removeTagFilterSegment)); + } + return results; + }); + }); + } + + segmentChanged(segment, index) { + this.segments[index] = segment; + + // handle remove tag condition + if (segment.value === this.removeTagFilterSegment.value) { + this.segments.splice(index, 3); + if (this.segments.length === 0) { + this.segments.push(this.uiSegmentSrv.newPlusButton()); + } else if (this.segments.length > 2) { + this.segments.splice(Math.max(index-1, 0), 1); + if (this.segments[this.segments.length-1].type !== 'plus-button') { + this.segments.push(this.uiSegmentSrv.newPlusButton()); + } + } + } else { + if (segment.type === 'plus-button') { + if (index > 2) { + this.segments.splice(index, 0, this.uiSegmentSrv.newCondition('AND')); + } + this.segments.push(this.uiSegmentSrv.newOperator('=')); + this.segments.push(this.uiSegmentSrv.newFake('select tag value', 'value', 'query-segment-value')); + segment.type = 'key'; + segment.cssClass = 'query-segment-key'; + } + + if ((index+1) === this.segments.length) { + this.segments.push(this.uiSegmentSrv.newPlusButton()); + } + } + + this.updateVariableModel(); + } + + updateVariableModel() { + var filters = []; + var filterIndex = -1; + var operator = ""; + var hasFakes = false; + + this.segments.forEach(segment => { + if (segment.type === 'value' && segment.fake) { + hasFakes = true; + return; + } + + switch (segment.type) { + case 'key': { + filters.push({key: segment.value}); + filterIndex += 1; + break; + } + case 'value': { + filters[filterIndex].value = segment.value; + break; + } + case 'operator': { + filters[filterIndex].operator = segment.value; + break; + } + case 'condition': { + filters[filterIndex].condition = segment.value; + break; + } + } + }); + + if (hasFakes) { + return; + } + + this.variable.setFilters(filters); + this.$rootScope.$emit('template-variable-value-updated'); + this.$rootScope.$broadcast('refresh'); + } +} + +var template = ` +
+
+ +
+
+`; + +export function adHocFiltersComponent() { + return { + restrict: 'E', + template: template, + controller: AdHocFiltersCtrl, + bindToController: true, + controllerAs: 'ctrl', + scope: { + variable: "=" + } + }; +} + +coreModule.directive('adHocFilters', adHocFiltersComponent); diff --git a/public/app/features/dashboard/all.js b/public/app/features/dashboard/all.js index 6aea2efa9f1..b49b910f95f 100644 --- a/public/app/features/dashboard/all.js +++ b/public/app/features/dashboard/all.js @@ -7,7 +7,7 @@ define([ './rowCtrl', './shareModalCtrl', './shareSnapshotCtrl', - './dashboardSrv', + './dashboard_srv', './keybindings', './viewStateSrv', './timeSrv', @@ -20,4 +20,5 @@ define([ './import/dash_import', './export/export_modal', './dash_list_ctrl', + './ad_hoc_filters', ], function () {}); diff --git a/public/app/features/dashboard/dashboardSrv.js b/public/app/features/dashboard/dashboardSrv.js deleted file mode 100644 index 66801b64f00..00000000000 --- a/public/app/features/dashboard/dashboardSrv.js +++ /dev/null @@ -1,552 +0,0 @@ -define([ - 'angular', - 'jquery', - 'lodash', - 'moment', -], -function (angular, $, _, moment) { - 'use strict'; - - var module = angular.module('grafana.services'); - - module.factory('dashboardSrv', function(contextSrv) { - - function DashboardModel (data, meta) { - if (!data) { - data = {}; - } - - this.id = data.id || null; - this.title = data.title || 'No Title'; - this.autoUpdate = data.autoUpdate; - this.description = data.description; - this.tags = data.tags || []; - this.style = data.style || "dark"; - this.timezone = data.timezone || ''; - this.editable = data.editable !== false; - this.hideControls = data.hideControls || false; - this.sharedCrosshair = data.sharedCrosshair || false; - this.rows = data.rows || []; - this.time = data.time || { from: 'now-6h', to: 'now' }; - this.timepicker = data.timepicker || {}; - this.templating = this._ensureListExist(data.templating); - this.annotations = this._ensureListExist(data.annotations); - this.refresh = data.refresh; - this.snapshot = data.snapshot; - this.schemaVersion = data.schemaVersion || 0; - this.version = data.version || 0; - this.links = data.links || []; - this.gnetId = data.gnetId || null; - this._updateSchema(data); - this._initMeta(meta); - } - - var p = DashboardModel.prototype; - - p._initMeta = function(meta) { - meta = meta || {}; - - meta.canShare = meta.canShare !== false; - meta.canSave = meta.canSave !== false; - meta.canStar = meta.canStar !== false; - meta.canEdit = meta.canEdit !== false; - - if (!this.editable) { - meta.canEdit = false; - meta.canDelete = false; - meta.canSave = false; - this.hideControls = true; - } - - this.meta = meta; - }; - - // cleans meta data and other non peristent state - p.getSaveModelClone = function() { - var copy = $.extend(true, {}, this); - delete copy.meta; - return copy; - }; - - p._ensureListExist = function (data) { - if (!data) { data = {}; } - if (!data.list) { data.list = []; } - return data; - }; - - p.getNextPanelId = function() { - var i, j, row, panel, max = 0; - for (i = 0; i < this.rows.length; i++) { - row = this.rows[i]; - for (j = 0; j < row.panels.length; j++) { - panel = row.panels[j]; - if (panel.id > max) { max = panel.id; } - } - } - return max + 1; - }; - - p.forEachPanel = function(callback) { - var i, j, row; - for (i = 0; i < this.rows.length; i++) { - row = this.rows[i]; - for (j = 0; j < row.panels.length; j++) { - callback(row.panels[j], j, row, i); - } - } - }; - - p.getPanelById = function(id) { - for (var i = 0; i < this.rows.length; i++) { - var row = this.rows[i]; - for (var j = 0; j < row.panels.length; j++) { - var panel = row.panels[j]; - if (panel.id === id) { - return panel; - } - } - } - return null; - }; - - p.rowSpan = function(row) { - return _.reduce(row.panels, function(p,v) { - return p + v.span; - },0); - }; - - p.addPanel = function(panel, row) { - var rowSpan = this.rowSpan(row); - var panelCount = row.panels.length; - var space = (12 - rowSpan) - panel.span; - panel.id = this.getNextPanelId(); - - // try to make room of there is no space left - if (space <= 0) { - if (panelCount === 1) { - row.panels[0].span = 6; - panel.span = 6; - } - else if (panelCount === 2) { - row.panels[0].span = 4; - row.panels[1].span = 4; - panel.span = 4; - } - } - - row.panels.push(panel); - }; - - p.isSubmenuFeaturesEnabled = function() { - var visableTemplates = _.filter(this.templating.list, function(template) { - return template.hideVariable === undefined || template.hideVariable === false; - }); - - return visableTemplates.length > 0 || this.annotations.list.length > 0 || this.links.length > 0; - }; - - p.getPanelInfoById = function(panelId) { - var result = {}; - _.each(this.rows, function(row) { - _.each(row.panels, function(panel, index) { - if (panel.id === panelId) { - result.panel = panel; - result.row = row; - result.index = index; - } - }); - }); - - if (!result.panel) { - return null; - } - - return result; - }; - - p.duplicatePanel = function(panel, row) { - var rowIndex = _.indexOf(this.rows, row); - var newPanel = angular.copy(panel); - newPanel.id = this.getNextPanelId(); - - delete newPanel.repeat; - delete newPanel.repeatIteration; - delete newPanel.repeatPanelId; - delete newPanel.scopedVars; - - var currentRow = this.rows[rowIndex]; - currentRow.panels.push(newPanel); - return newPanel; - }; - - p.formatDate = function(date, format) { - date = moment.isMoment(date) ? date : moment(date); - format = format || 'YYYY-MM-DD HH:mm:ss'; - this.timezone = this.getTimezone(); - - return this.timezone === 'browser' ? - moment(date).format(format) : - moment.utc(date).format(format); - }; - - p.getRelativeTime = function(date) { - date = moment.isMoment(date) ? date : moment(date); - - return this.timezone === 'browser' ? - moment(date).fromNow() : - moment.utc(date).fromNow(); - }; - - p.getNextQueryLetter = function(panel) { - var letters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'; - - return _.find(letters, function(refId) { - return _.every(panel.targets, function(other) { - return other.refId !== refId; - }); - }); - }; - - p.isTimezoneUtc = function() { - return this.getTimezone() === 'utc'; - }; - - p.getTimezone = function() { - return this.timezone ? this.timezone : contextSrv.user.timezone; - }; - - p._updateSchema = function(old) { - var i, j, k; - var oldVersion = this.schemaVersion; - var panelUpgrades = []; - this.schemaVersion = 13; - - if (oldVersion === this.schemaVersion) { - return; - } - - // version 2 schema changes - if (oldVersion < 2) { - - if (old.services) { - if (old.services.filter) { - this.time = old.services.filter.time; - this.templating.list = old.services.filter.list || []; - } - delete this.services; - } - - panelUpgrades.push(function(panel) { - // rename panel type - if (panel.type === 'graphite') { - panel.type = 'graph'; - } - - if (panel.type !== 'graph') { - return; - } - - if (_.isBoolean(panel.legend)) { panel.legend = { show: panel.legend }; } - - if (panel.grid) { - if (panel.grid.min) { - panel.grid.leftMin = panel.grid.min; - delete panel.grid.min; - } - - if (panel.grid.max) { - panel.grid.leftMax = panel.grid.max; - delete panel.grid.max; - } - } - - if (panel.y_format) { - panel.y_formats[0] = panel.y_format; - delete panel.y_format; - } - - if (panel.y2_format) { - panel.y_formats[1] = panel.y2_format; - delete panel.y2_format; - } - }); - } - - // schema version 3 changes - if (oldVersion < 3) { - // ensure panel ids - var maxId = this.getNextPanelId(); - panelUpgrades.push(function(panel) { - if (!panel.id) { - panel.id = maxId; - maxId += 1; - } - }); - } - - // schema version 4 changes - if (oldVersion < 4) { - // move aliasYAxis changes - panelUpgrades.push(function(panel) { - if (panel.type !== 'graph') { return; } - _.each(panel.aliasYAxis, function(value, key) { - panel.seriesOverrides = [{ alias: key, yaxis: value }]; - }); - delete panel.aliasYAxis; - }); - } - - if (oldVersion < 6) { - // move pulldowns to new schema - var annotations = _.find(old.pulldowns, { type: 'annotations' }); - - if (annotations) { - this.annotations = { - list: annotations.annotations || [], - }; - } - - // update template variables - for (i = 0 ; i < this.templating.list.length; i++) { - var variable = this.templating.list[i]; - if (variable.datasource === void 0) { variable.datasource = null; } - if (variable.type === 'filter') { variable.type = 'query'; } - if (variable.type === void 0) { variable.type = 'query'; } - if (variable.allFormat === void 0) { variable.allFormat = 'glob'; } - } - } - - if (oldVersion < 7) { - if (old.nav && old.nav.length) { - this.timepicker = old.nav[0]; - delete this.nav; - } - - // ensure query refIds - panelUpgrades.push(function(panel) { - _.each(panel.targets, function(target) { - if (!target.refId) { - target.refId = this.getNextQueryLetter(panel); - } - }.bind(this)); - }); - } - - if (oldVersion < 8) { - panelUpgrades.push(function(panel) { - _.each(panel.targets, function(target) { - // update old influxdb query schema - if (target.fields && target.tags && target.groupBy) { - if (target.rawQuery) { - delete target.fields; - delete target.fill; - } else { - target.select = _.map(target.fields, function(field) { - var parts = []; - parts.push({type: 'field', params: [field.name]}); - parts.push({type: field.func, params: []}); - if (field.mathExpr) { - parts.push({type: 'math', params: [field.mathExpr]}); - } - if (field.asExpr) { - parts.push({type: 'alias', params: [field.asExpr]}); - } - return parts; - }); - delete target.fields; - _.each(target.groupBy, function(part) { - if (part.type === 'time' && part.interval) { - part.params = [part.interval]; - delete part.interval; - } - if (part.type === 'tag' && part.key) { - part.params = [part.key]; - delete part.key; - } - }); - - if (target.fill) { - target.groupBy.push({type: 'fill', params: [target.fill]}); - delete target.fill; - } - } - } - }); - }); - } - - // schema version 9 changes - if (oldVersion < 9) { - // move aliasYAxis changes - panelUpgrades.push(function(panel) { - if (panel.type !== 'singlestat' && panel.thresholds !== "") { return; } - - if (panel.thresholds) { - var k = panel.thresholds.split(","); - - if (k.length >= 3) { - k.shift(); - panel.thresholds = k.join(","); - } - } - }); - } - - // schema version 10 changes - if (oldVersion < 10) { - // move aliasYAxis changes - panelUpgrades.push(function(panel) { - if (panel.type !== 'table') { return; } - - _.each(panel.styles, function(style) { - if (style.thresholds && style.thresholds.length >= 3) { - var k = style.thresholds; - k.shift(); - style.thresholds = k; - } - }); - }); - } - - if (oldVersion < 12) { - // update template variables - _.each(this.templating.list, function(templateVariable) { - if (templateVariable.refresh) { templateVariable.refresh = 1; } - if (!templateVariable.refresh) { templateVariable.refresh = 0; } - if (templateVariable.hideVariable) { - templateVariable.hide = 2; - } else if (templateVariable.hideLabel) { - templateVariable.hide = 1; - } else { - templateVariable.hide = 0; - } - }); - } - - if (oldVersion < 12) { - // update graph yaxes changes - panelUpgrades.push(function(panel) { - if (panel.type !== 'graph') { return; } - if (!panel.grid) { return; } - - if (!panel.yaxes) { - panel.yaxes = [ - { - show: panel['y-axis'], - min: panel.grid.leftMin, - max: panel.grid.leftMax, - logBase: panel.grid.leftLogBase, - format: panel.y_formats[0], - label: panel.leftYAxisLabel, - }, - { - show: panel['y-axis'], - min: panel.grid.rightMin, - max: panel.grid.rightMax, - logBase: panel.grid.rightLogBase, - format: panel.y_formats[1], - label: panel.rightYAxisLabel, - } - ]; - - panel.xaxis = { - show: panel['x-axis'], - }; - - delete panel.grid.leftMin; - delete panel.grid.leftMax; - delete panel.grid.leftLogBase; - delete panel.grid.rightMin; - delete panel.grid.rightMax; - delete panel.grid.rightLogBase; - delete panel.y_formats; - delete panel.leftYAxisLabel; - delete panel.rightYAxisLabel; - delete panel['y-axis']; - delete panel['x-axis']; - } - }); - } - - if (oldVersion < 13) { - // update graph yaxes changes - panelUpgrades.push(function(panel) { - if (panel.type !== 'graph') { return; } - - panel.thresholds = []; - var t1 = {}, t2 = {}; - - if (panel.grid.threshold1 !== null) { - t1.value = panel.grid.threshold1; - if (panel.grid.thresholdLine) { - t1.line = true; - t1.lineColor = panel.grid.threshold1Color; - } else { - t1.fill = true; - t1.fillColor = panel.grid.threshold1Color; - } - } - - if (panel.grid.threshold2 !== null) { - t2.value = panel.grid.threshold2; - if (panel.grid.thresholdLine) { - t2.line = true; - t2.lineColor = panel.grid.threshold2Color; - } else { - t2.fill = true; - t2.fillColor = panel.grid.threshold2Color; - } - } - - if (_.isNumber(t1.value)) { - if (_.isNumber(t2.value)) { - if (t1.value > t2.value) { - t1.op = t2.op = '<'; - panel.thresholds.push(t2); - panel.thresholds.push(t1); - } else { - t1.op = t2.op = '>'; - panel.thresholds.push(t2); - panel.thresholds.push(t1); - } - } else { - t1.op = '>'; - panel.thresholds.push(t1); - } - } - - delete panel.grid.threshold1; - delete panel.grid.threshold1Color; - delete panel.grid.threshold2; - delete panel.grid.threshold2Color; - delete panel.grid.thresholdLine; - }); - } - - if (panelUpgrades.length === 0) { - return; - } - - for (i = 0; i < this.rows.length; i++) { - var row = this.rows[i]; - for (j = 0; j < row.panels.length; j++) { - for (k = 0; k < panelUpgrades.length; k++) { - panelUpgrades[k].call(this, row.panels[j]); - } - } - } - }; - - return { - create: function(dashboard, meta) { - return new DashboardModel(dashboard, meta); - }, - setCurrent: function(dashboard) { - this.currentDashboard = dashboard; - }, - getCurrent: function() { - return this.currentDashboard; - }, - }; - }); -}); diff --git a/public/app/features/dashboard/dashboard_ctrl.ts b/public/app/features/dashboard/dashboard_ctrl.ts index 162331c4a98..4daf8ef6a68 100644 --- a/public/app/features/dashboard/dashboard_ctrl.ts +++ b/public/app/features/dashboard/dashboard_ctrl.ts @@ -15,7 +15,7 @@ export class DashboardCtrl { private $rootScope, dashboardKeybindings, timeSrv, - templateValuesSrv, + variableSrv, dashboardSrv, unsavedChangesSrv, dynamicDashboardSrv, @@ -46,7 +46,7 @@ export class DashboardCtrl { // template values service needs to initialize completely before // the rest of the dashboard can load - templateValuesSrv.init(dashboard) + variableSrv.init(dashboard) // template values failes are non fatal .catch($scope.onInitFailed.bind(this, 'Templating init failed', false)) // continue @@ -87,7 +87,6 @@ export class DashboardCtrl { }; $scope.templateVariableUpdated = function() { - console.log('dynamic update'); dynamicDashboardSrv.update($scope.dashboard); }; diff --git a/public/app/features/dashboard/dashboard_srv.ts b/public/app/features/dashboard/dashboard_srv.ts new file mode 100644 index 00000000000..289e3a841f5 --- /dev/null +++ b/public/app/features/dashboard/dashboard_srv.ts @@ -0,0 +1,590 @@ +/// + +import config from 'app/core/config'; +import angular from 'angular'; +import moment from 'moment'; +import _ from 'lodash'; +import $ from 'jquery'; + +import {Emitter} from 'app/core/core'; +import {contextSrv} from 'app/core/services/context_srv'; +import coreModule from 'app/core/core_module'; + +export class DashboardModel { + id: any; + title: any; + autoUpdate: any; + description: any; + tags: any; + style: any; + timezone: any; + editable: any; + hideControls: any; + sharedCrosshair: any; + rows: any; + time: any; + timepicker: any; + templating: any; + annotations: any; + refresh: any; + snapshot: any; + schemaVersion: number; + version: number; + links: any; + gnetId: any; + meta: any; + events: any; + + constructor(data, meta) { + if (!data) { + data = {}; + } + + this.events = new Emitter(); + this.id = data.id || null; + this.title = data.title || 'No Title'; + this.autoUpdate = data.autoUpdate; + this.description = data.description; + this.tags = data.tags || []; + this.style = data.style || "dark"; + this.timezone = data.timezone || ''; + this.editable = data.editable !== false; + this.hideControls = data.hideControls || false; + this.sharedCrosshair = data.sharedCrosshair || false; + this.rows = data.rows || []; + this.time = data.time || { from: 'now-6h', to: 'now' }; + this.timepicker = data.timepicker || {}; + this.templating = this.ensureListExist(data.templating); + this.annotations = this.ensureListExist(data.annotations); + this.refresh = data.refresh; + this.snapshot = data.snapshot; + this.schemaVersion = data.schemaVersion || 0; + this.version = data.version || 0; + this.links = data.links || []; + this.gnetId = data.gnetId || null; + + this.updateSchema(data); + this.initMeta(meta); + } + + private initMeta(meta) { + meta = meta || {}; + + meta.canShare = meta.canShare !== false; + meta.canSave = meta.canSave !== false; + meta.canStar = meta.canStar !== false; + meta.canEdit = meta.canEdit !== false; + + if (!this.editable) { + meta.canEdit = false; + meta.canDelete = false; + meta.canSave = false; + this.hideControls = true; + } + + this.meta = meta; + } + + // cleans meta data and other non peristent state + getSaveModelClone() { + // temp remove stuff + var events = this.events; + var meta = this.meta; + delete this.events; + delete this.meta; + + events.emit('prepare-save-model'); + var copy = $.extend(true, {}, this); + + // restore properties + this.events = events; + this.meta = meta; + return copy; + } + + private ensureListExist(data) { + if (!data) { data = {}; } + if (!data.list) { data.list = []; } + return data; + } + + getNextPanelId() { + var i, j, row, panel, max = 0; + for (i = 0; i < this.rows.length; i++) { + row = this.rows[i]; + for (j = 0; j < row.panels.length; j++) { + panel = row.panels[j]; + if (panel.id > max) { max = panel.id; } + } + } + return max + 1; + } + + forEachPanel(callback) { + var i, j, row; + for (i = 0; i < this.rows.length; i++) { + row = this.rows[i]; + for (j = 0; j < row.panels.length; j++) { + callback(row.panels[j], j, row, i); + } + } + } + + getPanelById(id) { + for (var i = 0; i < this.rows.length; i++) { + var row = this.rows[i]; + for (var j = 0; j < row.panels.length; j++) { + var panel = row.panels[j]; + if (panel.id === id) { + return panel; + } + } + } + return null; + } + + rowSpan(row) { + return _.reduce(row.panels, function(p,v) { + return p + v.span; + },0); + }; + + addPanel(panel, row) { + var rowSpan = this.rowSpan(row); + var panelCount = row.panels.length; + var space = (12 - rowSpan) - panel.span; + panel.id = this.getNextPanelId(); + + // try to make room of there is no space left + if (space <= 0) { + if (panelCount === 1) { + row.panels[0].span = 6; + panel.span = 6; + } else if (panelCount === 2) { + row.panels[0].span = 4; + row.panels[1].span = 4; + panel.span = 4; + } + } + + row.panels.push(panel); + } + + isSubmenuFeaturesEnabled() { + var visableTemplates = _.filter(this.templating.list, function(template) { + return template.hideVariable === undefined || template.hideVariable === false; + }); + + return visableTemplates.length > 0 || this.annotations.list.length > 0 || this.links.length > 0; + } + + getPanelInfoById(panelId) { + var result: any = {}; + _.each(this.rows, function(row) { + _.each(row.panels, function(panel, index) { + if (panel.id === panelId) { + result.panel = panel; + result.row = row; + result.index = index; + } + }); + }); + + if (!result.panel) { + return null; + } + + return result; + } + + duplicatePanel(panel, row) { + var rowIndex = _.indexOf(this.rows, row); + var newPanel = angular.copy(panel); + newPanel.id = this.getNextPanelId(); + + delete newPanel.repeat; + delete newPanel.repeatIteration; + delete newPanel.repeatPanelId; + delete newPanel.scopedVars; + + var currentRow = this.rows[rowIndex]; + currentRow.panels.push(newPanel); + return newPanel; + } + + formatDate(date, format) { + date = moment.isMoment(date) ? date : moment(date); + format = format || 'YYYY-MM-DD HH:mm:ss'; + this.timezone = this.getTimezone(); + + return this.timezone === 'browser' ? + moment(date).format(format) : + moment.utc(date).format(format); + } + + getRelativeTime(date) { + date = moment.isMoment(date) ? date : moment(date); + + return this.timezone === 'browser' ? + moment(date).fromNow() : + moment.utc(date).fromNow(); + } + + getNextQueryLetter(panel) { + var letters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'; + + return _.find(letters, function(refId) { + return _.every(panel.targets, function(other) { + return other.refId !== refId; + }); + }); + } + + isTimezoneUtc() { + return this.getTimezone() === 'utc'; + } + + getTimezone() { + return this.timezone ? this.timezone : contextSrv.user.timezone; + } + + private updateSchema(old) { + var i, j, k; + var oldVersion = this.schemaVersion; + var panelUpgrades = []; + this.schemaVersion = 13; + + if (oldVersion === this.schemaVersion) { + return; + } + + // version 2 schema changes + if (oldVersion < 2) { + + if (old.services) { + if (old.services.filter) { + this.time = old.services.filter.time; + this.templating.list = old.services.filter.list || []; + } + } + + panelUpgrades.push(function(panel) { + // rename panel type + if (panel.type === 'graphite') { + panel.type = 'graph'; + } + + if (panel.type !== 'graph') { + return; + } + + if (_.isBoolean(panel.legend)) { panel.legend = { show: panel.legend }; } + + if (panel.grid) { + if (panel.grid.min) { + panel.grid.leftMin = panel.grid.min; + delete panel.grid.min; + } + + if (panel.grid.max) { + panel.grid.leftMax = panel.grid.max; + delete panel.grid.max; + } + } + + if (panel.y_format) { + panel.y_formats[0] = panel.y_format; + delete panel.y_format; + } + + if (panel.y2_format) { + panel.y_formats[1] = panel.y2_format; + delete panel.y2_format; + } + }); + } + + // schema version 3 changes + if (oldVersion < 3) { + // ensure panel ids + var maxId = this.getNextPanelId(); + panelUpgrades.push(function(panel) { + if (!panel.id) { + panel.id = maxId; + maxId += 1; + } + }); + } + + // schema version 4 changes + if (oldVersion < 4) { + // move aliasYAxis changes + panelUpgrades.push(function(panel) { + if (panel.type !== 'graph') { return; } + _.each(panel.aliasYAxis, function(value, key) { + panel.seriesOverrides = [{ alias: key, yaxis: value }]; + }); + delete panel.aliasYAxis; + }); + } + + if (oldVersion < 6) { + // move pulldowns to new schema + var annotations = _.find(old.pulldowns, { type: 'annotations' }); + + if (annotations) { + this.annotations = { + list: annotations.annotations || [], + }; + } + + // update template variables + for (i = 0 ; i < this.templating.list.length; i++) { + var variable = this.templating.list[i]; + if (variable.datasource === void 0) { variable.datasource = null; } + if (variable.type === 'filter') { variable.type = 'query'; } + if (variable.type === void 0) { variable.type = 'query'; } + if (variable.allFormat === void 0) { variable.allFormat = 'glob'; } + } + } + + if (oldVersion < 7) { + if (old.nav && old.nav.length) { + this.timepicker = old.nav[0]; + } + + // ensure query refIds + panelUpgrades.push(function(panel) { + _.each(panel.targets, function(target) { + if (!target.refId) { + target.refId = this.getNextQueryLetter(panel); + } + }.bind(this)); + }); + } + + if (oldVersion < 8) { + panelUpgrades.push(function(panel) { + _.each(panel.targets, function(target) { + // update old influxdb query schema + if (target.fields && target.tags && target.groupBy) { + if (target.rawQuery) { + delete target.fields; + delete target.fill; + } else { + target.select = _.map(target.fields, function(field) { + var parts = []; + parts.push({type: 'field', params: [field.name]}); + parts.push({type: field.func, params: []}); + if (field.mathExpr) { + parts.push({type: 'math', params: [field.mathExpr]}); + } + if (field.asExpr) { + parts.push({type: 'alias', params: [field.asExpr]}); + } + return parts; + }); + delete target.fields; + _.each(target.groupBy, function(part) { + if (part.type === 'time' && part.interval) { + part.params = [part.interval]; + delete part.interval; + } + if (part.type === 'tag' && part.key) { + part.params = [part.key]; + delete part.key; + } + }); + + if (target.fill) { + target.groupBy.push({type: 'fill', params: [target.fill]}); + delete target.fill; + } + } + } + }); + }); + } + + // schema version 9 changes + if (oldVersion < 9) { + // move aliasYAxis changes + panelUpgrades.push(function(panel) { + if (panel.type !== 'singlestat' && panel.thresholds !== "") { return; } + + if (panel.thresholds) { + var k = panel.thresholds.split(","); + + if (k.length >= 3) { + k.shift(); + panel.thresholds = k.join(","); + } + } + }); + } + + // schema version 10 changes + if (oldVersion < 10) { + // move aliasYAxis changes + panelUpgrades.push(function(panel) { + if (panel.type !== 'table') { return; } + + _.each(panel.styles, function(style) { + if (style.thresholds && style.thresholds.length >= 3) { + var k = style.thresholds; + k.shift(); + style.thresholds = k; + } + }); + }); + } + + if (oldVersion < 12) { + // update template variables + _.each(this.templating.list, function(templateVariable) { + if (templateVariable.refresh) { templateVariable.refresh = 1; } + if (!templateVariable.refresh) { templateVariable.refresh = 0; } + if (templateVariable.hideVariable) { + templateVariable.hide = 2; + } else if (templateVariable.hideLabel) { + templateVariable.hide = 1; + } else { + templateVariable.hide = 0; + } + }); + } + + if (oldVersion < 12) { + // update graph yaxes changes + panelUpgrades.push(function(panel) { + if (panel.type !== 'graph') { return; } + if (!panel.grid) { return; } + + if (!panel.yaxes) { + panel.yaxes = [ + { + show: panel['y-axis'], + min: panel.grid.leftMin, + max: panel.grid.leftMax, + logBase: panel.grid.leftLogBase, + format: panel.y_formats[0], + label: panel.leftYAxisLabel, + }, + { + show: panel['y-axis'], + min: panel.grid.rightMin, + max: panel.grid.rightMax, + logBase: panel.grid.rightLogBase, + format: panel.y_formats[1], + label: panel.rightYAxisLabel, + } + ]; + + panel.xaxis = { + show: panel['x-axis'], + }; + + delete panel.grid.leftMin; + delete panel.grid.leftMax; + delete panel.grid.leftLogBase; + delete panel.grid.rightMin; + delete panel.grid.rightMax; + delete panel.grid.rightLogBase; + delete panel.y_formats; + delete panel.leftYAxisLabel; + delete panel.rightYAxisLabel; + delete panel['y-axis']; + delete panel['x-axis']; + } + }); + } + + if (oldVersion < 13) { + // update graph yaxes changes + panelUpgrades.push(function(panel) { + if (panel.type !== 'graph') { return; } + + panel.thresholds = []; + var t1: any = {}, t2: any = {}; + + if (panel.grid.threshold1 !== null) { + t1.value = panel.grid.threshold1; + if (panel.grid.thresholdLine) { + t1.line = true; + t1.lineColor = panel.grid.threshold1Color; + } else { + t1.fill = true; + t1.fillColor = panel.grid.threshold1Color; + } + } + + if (panel.grid.threshold2 !== null) { + t2.value = panel.grid.threshold2; + if (panel.grid.thresholdLine) { + t2.line = true; + t2.lineColor = panel.grid.threshold2Color; + } else { + t2.fill = true; + t2.fillColor = panel.grid.threshold2Color; + } + } + + if (_.isNumber(t1.value)) { + if (_.isNumber(t2.value)) { + if (t1.value > t2.value) { + t1.op = t2.op = '<'; + panel.thresholds.push(t2); + panel.thresholds.push(t1); + } else { + t1.op = t2.op = '>'; + panel.thresholds.push(t2); + panel.thresholds.push(t1); + } + } else { + t1.op = '>'; + panel.thresholds.push(t1); + } + } + + delete panel.grid.threshold1; + delete panel.grid.threshold1Color; + delete panel.grid.threshold2; + delete panel.grid.threshold2Color; + delete panel.grid.thresholdLine; + }); + } + + if (panelUpgrades.length === 0) { + return; + } + + for (i = 0; i < this.rows.length; i++) { + var row = this.rows[i]; + for (j = 0; j < row.panels.length; j++) { + for (k = 0; k < panelUpgrades.length; k++) { + panelUpgrades[k].call(this, row.panels[j]); + } + } + } + } +} + + +export class DashboardSrv { + currentDashboard: any; + + create(dashboard, meta) { + return new DashboardModel(dashboard, meta); + } + + setCurrent(dashboard) { + this.currentDashboard = dashboard; + } + + getCurrent() { + return this.currentDashboard; + } +} + +coreModule.service('dashboardSrv', DashboardSrv); + diff --git a/public/app/features/dashboard/export/exporter.ts b/public/app/features/dashboard/export/exporter.ts index 958715b5cbb..b57f26ebb8b 100644 --- a/public/app/features/dashboard/export/exporter.ts +++ b/public/app/features/dashboard/export/exporter.ts @@ -24,6 +24,10 @@ export class DashboardExporter { var templateizeDatasourceUsage = obj => { promises.push(this.datasourceSrv.get(obj.datasource).then(ds => { + if (ds.meta.builtIn) { + return; + } + var refName = 'DS_' + ds.name.replace(' ', '_').toUpperCase(); datasources[refName] = { name: refName, @@ -46,11 +50,19 @@ export class DashboardExporter { // check up panel data sources for (let row of dash.rows) { - _.each(row.panels, (panel) => { + for (let panel of row.panels) { if (panel.datasource !== undefined) { templateizeDatasourceUsage(panel); } + if (panel.targets) { + for (let target of panel.targets) { + if (target.datasource !== undefined) { + templateizeDatasourceUsage(target); + } + } + } + var panelDef = config.panels[panel.type]; if (panelDef) { requires['panel' + panelDef.id] = { @@ -60,7 +72,7 @@ export class DashboardExporter { version: panelDef.info.version, }; } - }); + } } // templatize template vars diff --git a/public/app/features/dashboard/specs/dashboard_srv_specs.ts b/public/app/features/dashboard/specs/dashboard_srv_specs.ts new file mode 100644 index 00000000000..56ce355881a --- /dev/null +++ b/public/app/features/dashboard/specs/dashboard_srv_specs.ts @@ -0,0 +1,379 @@ +import {describe, beforeEach, it, sinon, expect, angularMocks} from 'test/lib/common'; + +import {DashboardSrv} from '../dashboard_srv'; + +describe('dashboardSrv', function() { + var _dashboardSrv; + + beforeEach(() => { + _dashboardSrv = new DashboardSrv(); + }); + + describe('when creating new dashboard with defaults only', function() { + var model; + + beforeEach(function() { + model = _dashboardSrv.create({}, {}); + }); + + it('should have title', function() { + expect(model.title).to.be('No Title'); + }); + + it('should have meta', function() { + expect(model.meta.canSave).to.be(true); + expect(model.meta.canShare).to.be(true); + }); + + it('should have default properties', function() { + expect(model.rows.length).to.be(0); + }); + }); + + describe('when getting next panel id', function() { + var model; + + beforeEach(function() { + model = _dashboardSrv.create({ + rows: [{ panels: [{ id: 5 }]}] + }); + }); + + it('should return max id + 1', function() { + expect(model.getNextPanelId()).to.be(6); + }); + }); + + describe('row and panel manipulation', function() { + var dashboard; + + beforeEach(function() { + dashboard = _dashboardSrv.create({}); + }); + + it('row span should sum spans', function() { + var spanLeft = dashboard.rowSpan({ panels: [{ span: 2 }, { span: 3 }] }); + expect(spanLeft).to.be(5); + }); + + it('adding default should split span in half', function() { + dashboard.rows = [{ panels: [{ span: 12, id: 7 }] }]; + dashboard.addPanel({span: 4}, dashboard.rows[0]); + + expect(dashboard.rows[0].panels[0].span).to.be(6); + expect(dashboard.rows[0].panels[1].span).to.be(6); + expect(dashboard.rows[0].panels[1].id).to.be(8); + }); + + it('duplicate panel should try to add it to same row', function() { + var panel = { span: 4, attr: '123', id: 10 }; + dashboard.rows = [{ panels: [panel] }]; + dashboard.duplicatePanel(panel, dashboard.rows[0]); + + expect(dashboard.rows[0].panels[0].span).to.be(4); + expect(dashboard.rows[0].panels[1].span).to.be(4); + expect(dashboard.rows[0].panels[1].attr).to.be('123'); + expect(dashboard.rows[0].panels[1].id).to.be(11); + }); + + it('duplicate panel should remove repeat data', function() { + var panel = { span: 4, attr: '123', id: 10, repeat: 'asd', scopedVars: { test: 'asd' }}; + dashboard.rows = [{ panels: [panel] }]; + dashboard.duplicatePanel(panel, dashboard.rows[0]); + + expect(dashboard.rows[0].panels[1].repeat).to.be(undefined); + expect(dashboard.rows[0].panels[1].scopedVars).to.be(undefined); + }); + + }); + + describe('when creating dashboard with editable false', function() { + var model; + + beforeEach(function() { + model = _dashboardSrv.create({ + editable: false + }); + }); + + it('should set editable false', function() { + expect(model.editable).to.be(false); + }); + + }); + + describe('when creating dashboard with old schema', function() { + var model; + var graph; + var singlestat; + var table; + + beforeEach(function() { + model = _dashboardSrv.create({ + services: { filter: { time: { from: 'now-1d', to: 'now'}, list: [{}] }}, + pulldowns: [ + {type: 'filtering', enable: true}, + {type: 'annotations', enable: true, annotations: [{name: 'old'}]} + ], + rows: [ + { + panels: [ + { + type: 'graph', legend: true, aliasYAxis: { test: 2 }, + y_formats: ['kbyte', 'ms'], + grid: { + min: 1, + max: 10, + rightMin: 5, + rightMax: 15, + leftLogBase: 1, + rightLogBase: 2, + threshold1: 200, + threshold2: 400, + threshold1Color: 'yellow', + threshold2Color: 'red', + }, + leftYAxisLabel: 'left label', + targets: [{refId: 'A'}, {}], + }, + { + type: 'singlestat', legend: true, thresholds: '10,20,30', aliasYAxis: { test: 2 }, grid: { min: 1, max: 10 }, + targets: [{refId: 'A'}, {}], + }, + { + type: 'table', legend: true, styles: [{ thresholds: ["10", "20", "30"]}, { thresholds: ["100", "200", "300"]}], + targets: [{refId: 'A'}, {}], + } + ] + } + ] + }); + + graph = model.rows[0].panels[0]; + singlestat = model.rows[0].panels[1]; + table = model.rows[0].panels[2]; + }); + + it('should have title', function() { + expect(model.title).to.be('No Title'); + }); + + it('should have panel id', function() { + expect(graph.id).to.be(1); + }); + + it('should move time and filtering list', function() { + expect(model.time.from).to.be('now-1d'); + expect(model.templating.list[0].allFormat).to.be('glob'); + }); + + it('graphite panel should change name too graph', function() { + expect(graph.type).to.be('graph'); + }); + + it('single stat panel should have two thresholds', function() { + expect(singlestat.thresholds).to.be('20,30'); + }); + + it('queries without refId should get it', function() { + expect(graph.targets[1].refId).to.be('B'); + }); + + it('update legend setting', function() { + expect(graph.legend.show).to.be(true); + }); + + it('move aliasYAxis to series override', function() { + expect(graph.seriesOverrides[0].alias).to.be("test"); + expect(graph.seriesOverrides[0].yaxis).to.be(2); + }); + + it('should move pulldowns to new schema', function() { + expect(model.annotations.list[0].name).to.be('old'); + }); + + it('table panel should only have two thresholds values', function() { + expect(table.styles[0].thresholds[0]).to.be("20"); + expect(table.styles[0].thresholds[1]).to.be("30"); + expect(table.styles[1].thresholds[0]).to.be("200"); + expect(table.styles[1].thresholds[1]).to.be("300"); + }); + + it('graph grid to yaxes options', function() { + expect(graph.yaxes[0].min).to.be(1); + expect(graph.yaxes[0].max).to.be(10); + expect(graph.yaxes[0].format).to.be('kbyte'); + expect(graph.yaxes[0].label).to.be('left label'); + expect(graph.yaxes[0].logBase).to.be(1); + expect(graph.yaxes[1].min).to.be(5); + expect(graph.yaxes[1].max).to.be(15); + expect(graph.yaxes[1].format).to.be('ms'); + expect(graph.yaxes[1].logBase).to.be(2); + + expect(graph.grid.rightMax).to.be(undefined); + expect(graph.grid.rightLogBase).to.be(undefined); + expect(graph.y_formats).to.be(undefined); + }); + + it('dashboard schema version should be set to latest', function() { + expect(model.schemaVersion).to.be(13); + }); + + it('graph thresholds should be migrated', function() { + expect(graph.thresholds.length).to.be(2); + expect(graph.thresholds[0].op).to.be('>'); + expect(graph.thresholds[0].value).to.be(400); + expect(graph.thresholds[0].fillColor).to.be('red'); + expect(graph.thresholds[1].value).to.be(200); + expect(graph.thresholds[1].fillColor).to.be('yellow'); + }); + }); + + describe('when creating dashboard model with missing list for annoations or templating', function() { + var model; + + beforeEach(function() { + model = _dashboardSrv.create({ + annotations: { + enable: true, + }, + templating: { + enable: true + } + }); + }); + + it('should add empty list', function() { + expect(model.annotations.list.length).to.be(0); + expect(model.templating.list.length).to.be(0); + }); + }); + + describe('Given editable false dashboard', function() { + var model; + + beforeEach(function() { + model = _dashboardSrv.create({ + editable: false, + }); + }); + + it('Should set meta canEdit and canSave to false', function() { + expect(model.meta.canSave).to.be(false); + expect(model.meta.canEdit).to.be(false); + }); + + it('getSaveModelClone should remove meta', function() { + var clone = model.getSaveModelClone(); + expect(clone.meta).to.be(undefined); + }); + }); + + describe('when loading dashboard with old influxdb query schema', function() { + var model; + var target; + + beforeEach(function() { + model = _dashboardSrv.create({ + rows: [{ + panels: [{ + type: 'graph', + grid: {}, + yaxes: [{}, {}], + targets: [{ + "alias": "$tag_datacenter $tag_source $col", + "column": "value", + "measurement": "logins.count", + "fields": [ + { + "func": "mean", + "name": "value", + "mathExpr": "*2", + "asExpr": "value" + }, + { + "name": "one-minute", + "func": "mean", + "mathExpr": "*3", + "asExpr": "one-minute" + } + ], + "tags": [], + "fill": "previous", + "function": "mean", + "groupBy": [ + { + "interval": "auto", + "type": "time" + }, + { + "key": "source", + "type": "tag" + }, + { + "type": "tag", + "key": "datacenter" + } + ], + }] + }] + }] + }); + + target = model.rows[0].panels[0].targets[0]; + }); + + it('should update query schema', function() { + expect(target.fields).to.be(undefined); + expect(target.select.length).to.be(2); + expect(target.select[0].length).to.be(4); + expect(target.select[0][0].type).to.be('field'); + expect(target.select[0][1].type).to.be('mean'); + expect(target.select[0][2].type).to.be('math'); + expect(target.select[0][3].type).to.be('alias'); + }); + + }); + + describe('when creating dashboard model with missing list for annoations or templating', function() { + var model; + + beforeEach(function() { + model = _dashboardSrv.create({ + annotations: { + enable: true, + }, + templating: { + enable: true + } + }); + }); + + it('should add empty list', function() { + expect(model.annotations.list.length).to.be(0); + expect(model.templating.list.length).to.be(0); + }); + }); + + describe('Formatting epoch timestamp when timezone is set as utc', function() { + var dashboard; + + beforeEach(function() { + dashboard = _dashboardSrv.create({ + timezone: 'utc', + }); + }); + + it('Should format timestamp with second resolution by default', function() { + expect(dashboard.formatDate(1234567890000)).to.be('2009-02-13 23:31:30'); + }); + + it('Should format timestamp with second resolution even if second format is passed as parameter', function() { + expect(dashboard.formatDate(1234567890007,'YYYY-MM-DD HH:mm:ss')).to.be('2009-02-13 23:31:30'); + }); + + it('Should format timestamp with millisecond resolution if format is passed as parameter', function() { + expect(dashboard.formatDate(1234567890007,'YYYY-MM-DD HH:mm:ss.SSS')).to.be('2009-02-13 23:31:30.007'); + }); + }); +}); diff --git a/public/app/features/dashboard/specs/dynamic_dashboard_srv_specs.ts b/public/app/features/dashboard/specs/dynamic_dashboard_srv_specs.ts index fbc1913ca30..0173c6569a5 100644 --- a/public/app/features/dashboard/specs/dynamic_dashboard_srv_specs.ts +++ b/public/app/features/dashboard/specs/dynamic_dashboard_srv_specs.ts @@ -1,6 +1,6 @@ import {describe, beforeEach, it, sinon, expect, angularMocks} from 'test/lib/common'; -import 'app/features/dashboard/dashboardSrv'; +import {DashboardSrv} from '../dashboard_srv'; import {DynamicDashboardSrv} from '../dynamic_dashboard_srv'; function dynamicDashScenario(desc, func) { @@ -10,6 +10,7 @@ function dynamicDashScenario(desc, func) { ctx.setup = function (setupFunc) { + beforeEach(angularMocks.module('grafana.core')); beforeEach(angularMocks.module('grafana.services')); beforeEach(angularMocks.module(function($provide) { $provide.value('contextSrv', { diff --git a/public/app/features/dashboard/specs/exporter_specs.ts b/public/app/features/dashboard/specs/exporter_specs.ts index eac40172513..16430f13916 100644 --- a/public/app/features/dashboard/specs/exporter_specs.ts +++ b/public/app/features/dashboard/specs/exporter_specs.ts @@ -42,21 +42,34 @@ describe('given dashboard with repeated panels', function() { repeat: 'test', panels: [ {id: 2, repeat: 'apps', datasource: 'gfdb', type: 'graph'}, - {id: 2, repeat: null, repeatPanelId: 2}, + {id: 3, repeat: null, repeatPanelId: 2}, + { + id: 4, + datasource: '-- Mixed --', + targets: [{datasource: 'other'}], + }, ] }); + dash.rows.push({ repeat: null, repeatRowId: 1, panels: [], }); - var datasourceSrvStub = { - get: sinon.stub().returns(Promise.resolve({ - name: 'gfdb', - meta: {id: "testdb", info: {version: "1.2.1"}, name: "TestDB"} - })) - }; + var datasourceSrvStub = {get: sinon.stub()}; + datasourceSrvStub.get.withArgs('gfdb').returns(Promise.resolve({ + name: 'gfdb', + meta: {id: "testdb", info: {version: "1.2.1"}, name: "TestDB"} + })); + datasourceSrvStub.get.withArgs('other').returns(Promise.resolve({ + name: 'other', + meta: {id: "other", info: {version: "1.2.1"}, name: "OtherDB"} + })); + datasourceSrvStub.get.withArgs('-- Mixed --').returns(Promise.resolve({ + name: 'mixed', + meta: {id: "mixed", info: {version: "1.2.1"}, name: "Mixed", builtIn: true} + })); config.panels['graph'] = { id: "graph", @@ -72,7 +85,7 @@ describe('given dashboard with repeated panels', function() { }); it('exported dashboard should not contain repeated panels', function() { - expect(exported.rows[0].panels.length).to.be(1); + expect(exported.rows[0].panels.length).to.be(2); }); it('exported dashboard should not contain repeated rows', function() { @@ -109,6 +122,16 @@ describe('given dashboard with repeated panels', function() { expect(require.version).to.be("1.2.1"); }); + it('should not add built in datasources to required', function() { + var require = _.find(exported.__requires, {name: 'Mixed'}); + expect(require).to.be(undefined); + }); + + it('should add datasources used in mixed mode', function() { + var require = _.find(exported.__requires, {name: 'OtherDB'}); + expect(require).to.not.be(undefined); + }); + it('should add panel to required', function() { var require = _.find(exported.__requires, {name: 'Graph'}); expect(require.name).to.be("Graph"); diff --git a/public/app/features/dashboard/submenu/submenu.html b/public/app/features/dashboard/submenu/submenu.html index 464d8c4cecf..2b2f7af6fbe 100644 --- a/public/app/features/dashboard/submenu/submenu.html +++ b/public/app/features/dashboard/submenu/submenu.html @@ -1,27 +1,26 @@ - -
+
Interval Options
Values - +
Auto option @@ -144,15 +133,15 @@
-
+
Custom Options
Values separated by comma - +
-
+
Constant options
Value @@ -160,14 +149,14 @@
-
+
Query Options
- Data source + Data source
- +
@@ -181,21 +170,10 @@
-
- - Sort - - How to sort the values of this variable. - - -
- -
-
-
-
+
+
Query - +
@@ -206,15 +184,26 @@
-
+
+ + Sort + + How to sort the values of this variable. + + +
+ +
+
+
-
-
Data source options
+
+
Data source options
-
- -
- +
+ +
+
@@ -233,8 +222,18 @@
-
-
Selection Options
+
+
Options
+
+ Data source +
+ +
+
+
+ +
+
Selection Options
-
+
Preview of values (shows max 20)
@@ -279,12 +278,17 @@
-
-
- - -
-
+
+ {{infoText}} +
+ +
+ + +
+ + +
diff --git a/public/app/features/templating/query_variable.ts b/public/app/features/templating/query_variable.ts new file mode 100644 index 00000000000..96766d1bbfb --- /dev/null +++ b/public/app/features/templating/query_variable.ts @@ -0,0 +1,167 @@ +/// + +import _ from 'lodash'; +import kbn from 'app/core/utils/kbn'; +import {Variable, containsVariable, assignModelProperties, variableTypes} from './variable'; +import {VariableSrv} from './variable_srv'; + +function getNoneOption() { + return { text: 'None', value: '', isNone: true }; +} + +export class QueryVariable implements Variable { + datasource: any; + query: any; + regex: any; + sort: any; + options: any; + current: any; + refresh: number; + hide: number; + name: string; + multi: boolean; + includeAll: boolean; + + defaults = { + type: 'query', + query: '', + regex: '', + sort: 0, + datasource: null, + refresh: 0, + hide: 0, + name: '', + multi: false, + includeAll: false, + allValue: null, + options: [], + current: {}, + tagsQuery: null, + tagValuesQuery: null, + }; + + constructor(private model, private datasourceSrv, private templateSrv, private variableSrv, private $q) { + // copy model properties to this instance + assignModelProperties(this, model, this.defaults); + } + + getModel() { + // copy back model properties to model + assignModelProperties(this.model, this, this.defaults); + return this.model; + } + + setValue(option){ + return this.variableSrv.setOptionAsCurrent(this, option); + } + + setValueFromUrl(urlValue) { + return this.variableSrv.setOptionFromUrl(this, urlValue); + } + + getValueForUrl() { + if (this.current.text === 'All') { + return 'All'; + } + return this.current.value; + } + + updateOptions() { + return this.datasourceSrv.get(this.datasource) + .then(this.updateOptionsFromMetricFindQuery.bind(this)) + .then(this.variableSrv.validateVariableSelectionState.bind(this.variableSrv, this)); + } + + updateOptionsFromMetricFindQuery(datasource) { + return datasource.metricFindQuery(this.query).then(results => { + this.options = this.metricNamesToVariableValues(results); + if (this.includeAll) { + this.addAllOption(); + } + if (!this.options.length) { + this.options.push(getNoneOption()); + } + return datasource; + }); + } + + addAllOption() { + this.options.unshift({text: 'All', value: "$__all"}); + } + + metricNamesToVariableValues(metricNames) { + var regex, options, i, matches; + options = []; + + if (this.regex) { + regex = kbn.stringToJsRegex(this.templateSrv.replace(this.regex)); + } + + for (i = 0; i < metricNames.length; i++) { + var item = metricNames[i]; + var value = item.value || item.text; + var text = item.text || item.value; + + if (_.isNumber(value)) { + value = value.toString(); + } + + if (_.isNumber(text)) { + text = text.toString(); + } + + if (regex) { + matches = regex.exec(value); + if (!matches) { continue; } + if (matches.length > 1) { + value = matches[1]; + text = matches[1]; + } + } + + options.push({text: text, value: value}); + } + + options = _.uniqBy(options, 'value'); + return this.sortVariableValues(options, this.sort); + } + + sortVariableValues(options, sortOrder) { + if (sortOrder === 0) { + return options; + } + + var sortType = Math.ceil(sortOrder / 2); + var reverseSort = (sortOrder % 2 === 0); + + if (sortType === 1) { + options = _.sortBy(options, 'text'); + } else if (sortType === 2) { + options = _.sortBy(options, function(opt) { + var matches = opt.text.match(/.*?(\d+).*/); + if (!matches) { + return 0; + } else { + return parseInt(matches[1], 10); + } + }); + } + + if (reverseSort) { + options = options.reverse(); + } + + return options; + } + + dependsOn(variable) { + return containsVariable(this.query, this.datasource, variable.name); + } +} + +variableTypes['query'] = { + name: 'Query', + ctor: QueryVariable, + description: 'Variable values are fetched from a datasource query', + supportsMulti: true, +}; diff --git a/public/app/features/templating/specs/adhoc_variable_specs.ts b/public/app/features/templating/specs/adhoc_variable_specs.ts new file mode 100644 index 00000000000..15856940540 --- /dev/null +++ b/public/app/features/templating/specs/adhoc_variable_specs.ts @@ -0,0 +1,40 @@ +import {describe, beforeEach, it, sinon, expect, angularMocks} from 'test/lib/common'; + +import {AdhocVariable} from '../adhoc_variable'; + +describe('AdhocVariable', function() { + + describe('when serializing to url', function() { + + it('should set return key value and op seperated by pipe', function() { + var variable = new AdhocVariable({ + filters: [ + {key: 'key1', operator: '=', value: 'value1'}, + {key: 'key2', operator: '!=', value: 'value2'}, + ] + }); + var urlValue = variable.getValueForUrl(); + expect(urlValue).to.eql(["key1|=|value1", "key2|!=|value2"]); + }); + + }); + + describe('when deserializing from url', function() { + + it('should restore filters', function() { + var variable = new AdhocVariable({}); + variable.setValueFromUrl(["key1|=|value1", "key2|!=|value2"]); + + expect(variable.filters[0].key).to.be('key1'); + expect(variable.filters[0].operator).to.be('='); + expect(variable.filters[0].value).to.be('value1'); + + expect(variable.filters[1].key).to.be('key2'); + expect(variable.filters[1].operator).to.be('!='); + expect(variable.filters[1].value).to.be('value2'); + }); + + }); + +}); + diff --git a/public/app/features/templating/specs/query_variable_specs.ts b/public/app/features/templating/specs/query_variable_specs.ts new file mode 100644 index 00000000000..8a2aef65be2 --- /dev/null +++ b/public/app/features/templating/specs/query_variable_specs.ts @@ -0,0 +1,39 @@ +import {describe, beforeEach, it, sinon, expect, angularMocks} from 'test/lib/common'; + +import {QueryVariable} from '../query_variable'; + +describe('QueryVariable', function() { + + describe('when creating from model', function() { + + it('should set defaults', function() { + var variable = new QueryVariable({}, null, null, null, null); + expect(variable.datasource).to.be(null); + expect(variable.refresh).to.be(0); + expect(variable.sort).to.be(0); + expect(variable.name).to.be(''); + expect(variable.hide).to.be(0); + expect(variable.options.length).to.be(0); + expect(variable.multi).to.be(false); + expect(variable.includeAll).to.be(false); + }); + + it('get model should copy changes back to model', () => { + var variable = new QueryVariable({}, null, null, null, null); + variable.options = [{text: 'test'}]; + variable.datasource = 'google'; + variable.regex = 'asd'; + variable.sort = 50; + + var model = variable.getModel(); + expect(model.options.length).to.be(1); + expect(model.options[0].text).to.be('test'); + expect(model.datasource).to.be('google'); + expect(model.regex).to.be('asd'); + expect(model.sort).to.be(50); + }); + + }); + +}); + diff --git a/public/app/features/templating/specs/template_srv_specs.ts b/public/app/features/templating/specs/template_srv_specs.ts new file mode 100644 index 00000000000..94b1e211293 --- /dev/null +++ b/public/app/features/templating/specs/template_srv_specs.ts @@ -0,0 +1,237 @@ +import {describe, beforeEach, it, sinon, expect, angularMocks} from 'test/lib/common'; + +import '../all'; +import {Emitter} from 'app/core/core'; + +describe('templateSrv', function() { + var _templateSrv, _variableSrv; + + beforeEach(angularMocks.module('grafana.core')); + beforeEach(angularMocks.module('grafana.services')); + + beforeEach(angularMocks.inject(function(variableSrv, templateSrv) { + _templateSrv = templateSrv; + _variableSrv = variableSrv; + })); + + function initTemplateSrv(variables) { + _variableSrv.init({ + templating: {list: variables}, + events: new Emitter(), + }); + } + + describe('init', function() { + beforeEach(function() { + initTemplateSrv([{type: 'query', name: 'test', current: {value: 'oogle'}}]); + }); + + it('should initialize template data', function() { + var target = _templateSrv.replace('this.[[test]].filters'); + expect(target).to.be('this.oogle.filters'); + }); + }); + + describe('replace can pass scoped vars', function() { + beforeEach(function() { + initTemplateSrv([{type: 'query', name: 'test', current: {value: 'oogle' }}]); + }); + + it('should replace $test with scoped value', function() { + var target = _templateSrv.replace('this.$test.filters', {'test': {value: 'mupp', text: 'asd'}}); + expect(target).to.be('this.mupp.filters'); + }); + + it('should replace $test with scoped text', function() { + var target = _templateSrv.replaceWithText('this.$test.filters', {'test': {value: 'mupp', text: 'asd'}}); + expect(target).to.be('this.asd.filters'); + }); + }); + + describe('replace can pass multi / all format', function() { + beforeEach(function() { + initTemplateSrv([{type: 'query', name: 'test', current: {value: ['value1', 'value2'] }}]); + }); + + it('should replace $test with globbed value', function() { + var target = _templateSrv.replace('this.$test.filters', {}, 'glob'); + expect(target).to.be('this.{value1,value2}.filters'); + }); + + it('should replace $test with piped value', function() { + var target = _templateSrv.replace('this=$test', {}, 'pipe'); + expect(target).to.be('this=value1|value2'); + }); + + it('should replace $test with piped value', function() { + var target = _templateSrv.replace('this=$test', {}, 'pipe'); + expect(target).to.be('this=value1|value2'); + }); + }); + + describe('variable with all option', function() { + beforeEach(function() { + initTemplateSrv([{ + type: 'query', + name: 'test', + current: {value: '$__all' }, + options: [ + {value: '$__all'}, {value: 'value1'}, {value: 'value2'} + ] + }]); + }); + + it('should replace $test with formatted all value', function() { + var target = _templateSrv.replace('this.$test.filters', {}, 'glob'); + expect(target).to.be('this.{value1,value2}.filters'); + }); + }); + + describe('variable with all option and custom value', function() { + beforeEach(function() { + initTemplateSrv([{ + type: 'query', + name: 'test', + current: {value: '$__all' }, + allValue: '*', + options: [ + {value: 'value1'}, {value: 'value2'} + ] + }]); + }); + + it('should replace $test with formatted all value', function() { + var target = _templateSrv.replace('this.$test.filters', {}, 'glob'); + expect(target).to.be('this.*.filters'); + }); + + it('should not escape custom all value', function() { + var target = _templateSrv.replace('this.$test', {}, 'regex'); + expect(target).to.be('this.*'); + }); + }); + + describe('lucene format', function() { + it('should properly escape $test with lucene escape sequences', function() { + initTemplateSrv([{type: 'query', name: 'test', current: {value: 'value/4' }}]); + var target = _templateSrv.replace('this:$test', {}, 'lucene'); + expect(target).to.be("this:value\\\/4"); + }); + }); + + describe('format variable to string values', function() { + it('single value should return value', function() { + var result = _templateSrv.formatValue('test'); + expect(result).to.be('test'); + }); + + it('multi value and glob format should render glob string', function() { + var result = _templateSrv.formatValue(['test','test2'], 'glob'); + expect(result).to.be('{test,test2}'); + }); + + it('multi value and lucene should render as lucene expr', function() { + var result = _templateSrv.formatValue(['test','test2'], 'lucene'); + expect(result).to.be('("test" OR "test2")'); + }); + + it('multi value and regex format should render regex string', function() { + var result = _templateSrv.formatValue(['test.','test2'], 'regex'); + expect(result).to.be('(test\\.|test2)'); + }); + + it('multi value and pipe should render pipe string', function() { + var result = _templateSrv.formatValue(['test','test2'], 'pipe'); + expect(result).to.be('test|test2'); + }); + + it('slash should be properly escaped in regex format', function() { + var result = _templateSrv.formatValue('Gi3/14', 'regex'); + expect(result).to.be('Gi3\\/14'); + }); + + }); + + describe('can check if variable exists', function() { + beforeEach(function() { + initTemplateSrv([{type: 'query', name: 'test', current: { value: 'oogle' } }]); + }); + + it('should return true if exists', function() { + var result = _templateSrv.variableExists('$test'); + expect(result).to.be(true); + }); + }); + + describe('can hightlight variables in string', function() { + beforeEach(function() { + initTemplateSrv([{type: 'query', name: 'test', current: { value: 'oogle' } }]); + }); + + it('should insert html', function() { + var result = _templateSrv.highlightVariablesAsHtml('$test'); + expect(result).to.be('$test'); + }); + + it('should insert html anywhere in string', function() { + var result = _templateSrv.highlightVariablesAsHtml('this $test ok'); + expect(result).to.be('this $test ok'); + }); + + it('should ignore if variables does not exist', function() { + var result = _templateSrv.highlightVariablesAsHtml('this $google ok'); + expect(result).to.be('this $google ok'); + }); + }); + + describe('updateTemplateData with simple value', function() { + beforeEach(function() { + initTemplateSrv([{type: 'query', name: 'test', current: { value: 'muuuu' } }]); + }); + + it('should set current value and update template data', function() { + var target = _templateSrv.replace('this.[[test]].filters'); + expect(target).to.be('this.muuuu.filters'); + }); + }); + + describe('fillVariableValuesForUrl with multi value', function() { + beforeEach(function() { + initTemplateSrv([{type: 'query', name: 'test', current: { value: ['val1', 'val2'] }}]); + }); + + it('should set multiple url params', function() { + var params = {}; + _templateSrv.fillVariableValuesForUrl(params); + expect(params['var-test']).to.eql(['val1', 'val2']); + }); + }); + + describe('fillVariableValuesForUrl with multi value and scopedVars', function() { + beforeEach(function() { + initTemplateSrv([{type: 'query', name: 'test', current: { value: ['val1', 'val2'] }}]); + }); + + it('should set scoped value as url params', function() { + var params = {}; + _templateSrv.fillVariableValuesForUrl(params, {'test': {value: 'val1'}}); + expect(params['var-test']).to.eql('val1'); + }); + }); + + describe('replaceWithText', function() { + beforeEach(function() { + initTemplateSrv([ + {type: 'query', name: 'server', current: { value: '{asd,asd2}', text: 'All' } }, + {type: 'interval', name: 'period', current: { value: '$__auto_interval', text: 'auto' } } + ]); + _templateSrv.setGrafanaVariable('$__auto_interval', '13m'); + _templateSrv.updateTemplateData(); + }); + + it('should replace with text except for grafanaVariables', function() { + var target = _templateSrv.replaceWithText('Server: $server, period: $period'); + expect(target).to.be('Server: All, period: 13m'); + }); + }); +}); diff --git a/public/app/features/templating/specs/variable_specs.ts b/public/app/features/templating/specs/variable_specs.ts new file mode 100644 index 00000000000..9a974eae695 --- /dev/null +++ b/public/app/features/templating/specs/variable_specs.ts @@ -0,0 +1,59 @@ +import {describe, beforeEach, it, sinon, expect, angularMocks} from 'test/lib/common'; + +import {containsVariable, assignModelProperties} from '../variable'; + +describe('containsVariable', function() { + + describe('when checking if a string contains a variable', function() { + + it('should find it with $var syntax', function() { + var contains = containsVariable('this.$test.filters', 'test'); + expect(contains).to.be(true); + }); + + it('should not find it if only part matches with $var syntax', function() { + var contains = containsVariable('this.$ServerDomain.filters', 'Server'); + expect(contains).to.be(false); + }); + + it('should find it with [[var]] syntax', function() { + var contains = containsVariable('this.[[test]].filters', 'test'); + expect(contains).to.be(true); + }); + + it('should find it when part of segment', function() { + var contains = containsVariable('metrics.$env.$group-*', 'group'); + expect(contains).to.be(true); + }); + + it('should find it its the only thing', function() { + var contains = containsVariable('$env', 'env'); + expect(contains).to.be(true); + }); + + it('should be able to pass in multiple test strings', function() { + var contains = containsVariable('asd','asd2.$env', 'env'); + expect(contains).to.be(true); + }); + + }); + +}); + +describe('assignModelProperties', function() { + + it('only set properties defined in defaults', function() { + var target: any = {test: 'asd'}; + assignModelProperties(target, {propA: 1, propB: 2}, {propB: 0}); + expect(target.propB).to.be(2); + expect(target.test).to.be('asd'); + }); + + it('use default value if not found on source', function() { + var target: any = {test: 'asd'}; + assignModelProperties(target, {propA: 1, propB: 2}, {propC: 10}); + expect(target.propC).to.be(10); + }); + +}); + diff --git a/public/app/features/templating/specs/variable_srv_init_specs.ts b/public/app/features/templating/specs/variable_srv_init_specs.ts new file mode 100644 index 00000000000..8cac63135ca --- /dev/null +++ b/public/app/features/templating/specs/variable_srv_init_specs.ts @@ -0,0 +1,142 @@ +import {describe, beforeEach, it, sinon, expect, angularMocks} from 'test/lib/common'; + +import '../all'; + +import _ from 'lodash'; +import helpers from 'test/specs/helpers'; +import {Emitter} from 'app/core/core'; + +describe('VariableSrv init', function() { + var ctx = new helpers.ControllerTestContext(); + + beforeEach(angularMocks.module('grafana.core')); + beforeEach(angularMocks.module('grafana.controllers')); + beforeEach(angularMocks.module('grafana.services')); + + beforeEach(ctx.providePhase(['datasourceSrv', 'timeSrv', 'templateSrv', '$location'])); + beforeEach(angularMocks.inject(($rootScope, $q, $location, $injector) => { + ctx.$q = $q; + ctx.$rootScope = $rootScope; + ctx.$location = $location; + ctx.variableSrv = $injector.get('variableSrv'); + ctx.$rootScope.$digest(); + })); + + function describeInitScenario(desc, fn) { + describe(desc, function() { + var scenario: any = { + urlParams: {}, + setup: setupFn => { + scenario.setupFn = setupFn; + } + }; + + beforeEach(function() { + scenario.setupFn(); + ctx.datasource = {}; + ctx.datasource.metricFindQuery = sinon.stub().returns(ctx.$q.when(scenario.queryResult)); + + ctx.datasourceSrv.get = sinon.stub().returns(ctx.$q.when(ctx.datasource)); + ctx.datasourceSrv.getMetricSources = sinon.stub().returns(scenario.metricSources); + + ctx.$location.search = sinon.stub().returns(scenario.urlParams); + ctx.dashboard = {templating: {list: scenario.variables}, events: new Emitter()}; + + ctx.variableSrv.init(ctx.dashboard); + ctx.$rootScope.$digest(); + + scenario.variables = ctx.variableSrv.variables; + }); + + fn(scenario); + }); + } + + ['query', 'interval', 'custom', 'datasource'].forEach(type => { + describeInitScenario('when setting ' + type + ' variable via url', scenario => { + scenario.setup(() => { + scenario.variables = [{ + name: 'apps', + type: type, + current: {text: "test", value: "test"}, + options: [{text: "test", value: "test"}] + }]; + scenario.urlParams["var-apps"] = "new"; + }); + + it('should update current value', () => { + expect(scenario.variables[0].current.value).to.be("new"); + expect(scenario.variables[0].current.text).to.be("new"); + }); + }); + + }); + + describe('given dependent variables', () => { + var variableList = [ + { + name: 'app', + type: 'query', + query: '', + current: {text: "app1", value: "app1"}, + options: [{text: "app1", value: "app1"}] + }, + { + name: 'server', + type: 'query', + refresh: 1, + query: '$app.*', + current: {text: "server1", value: "server1"}, + options: [{text: "server1", value: "server1"}] + }, + ]; + + describeInitScenario('when setting parent var from url', scenario => { + scenario.setup(() => { + scenario.variables = _.cloneDeep(variableList); + scenario.urlParams["var-app"] = "google"; + scenario.queryResult = [{text: 'google-server1'}, {text: 'google-server2'}]; + }); + + it('should update child variable', () => { + expect(scenario.variables[1].options.length).to.be(2); + expect(scenario.variables[1].current.text).to.be("google-server1"); + }); + + it('should only update it once', () => { + expect(ctx.datasource.metricFindQuery.callCount).to.be(1); + }); + + }); + }); + + describeInitScenario('when template variable is present in url multiple times', scenario => { + scenario.setup(() => { + scenario.variables = [{ + name: 'apps', + type: 'query', + multi: true, + current: {text: "val1", value: "val1"}, + options: [{text: "val1", value: "val1"}, {text: 'val2', value: 'val2'}, {text: 'val3', value: 'val3', selected: true}] + }]; + scenario.urlParams["var-apps"] = ["val2", "val1"]; + }); + + it('should update current value', function() { + var variable = ctx.variableSrv.variables[0]; + expect(variable.current.value.length).to.be(2); + expect(variable.current.value[0]).to.be("val2"); + expect(variable.current.value[1]).to.be("val1"); + expect(variable.current.text).to.be("val2 + val1"); + expect(variable.options[0].selected).to.be(true); + expect(variable.options[1].selected).to.be(true); + }); + + it('should set options that are not in value to selected false', function() { + var variable = ctx.variableSrv.variables[0]; + expect(variable.options[2].selected).to.be(false); + }); + }); + +}); + diff --git a/public/app/features/templating/specs/variable_srv_specs.ts b/public/app/features/templating/specs/variable_srv_specs.ts new file mode 100644 index 00000000000..85bca8d6068 --- /dev/null +++ b/public/app/features/templating/specs/variable_srv_specs.ts @@ -0,0 +1,395 @@ +import {describe, beforeEach, it, sinon, expect, angularMocks} from 'test/lib/common'; + +import '../all'; + +import moment from 'moment'; +import helpers from 'test/specs/helpers'; +import {Emitter} from 'app/core/core'; + +describe('VariableSrv', function() { + var ctx = new helpers.ControllerTestContext(); + + beforeEach(angularMocks.module('grafana.core')); + beforeEach(angularMocks.module('grafana.controllers')); + beforeEach(angularMocks.module('grafana.services')); + + beforeEach(ctx.providePhase(['datasourceSrv', 'timeSrv', 'templateSrv', '$location'])); + beforeEach(angularMocks.inject(($rootScope, $q, $location, $injector) => { + ctx.$q = $q; + ctx.$rootScope = $rootScope; + ctx.$location = $location; + ctx.variableSrv = $injector.get('variableSrv'); + ctx.variableSrv.init({ + templating: {list: []}, + events: new Emitter(), + }); + ctx.$rootScope.$digest(); + })); + + function describeUpdateVariable(desc, fn) { + describe(desc, function() { + var scenario: any = {}; + scenario.setup = function(setupFn) { + scenario.setupFn = setupFn; + }; + + beforeEach(function() { + scenario.setupFn(); + var ds: any = {}; + ds.metricFindQuery = sinon.stub().returns(ctx.$q.when(scenario.queryResult)); + ctx.datasourceSrv.get = sinon.stub().returns(ctx.$q.when(ds)); + ctx.datasourceSrv.getMetricSources = sinon.stub().returns(scenario.metricSources); + + + scenario.variable = ctx.variableSrv.addVariable(scenario.variableModel); + ctx.variableSrv.updateOptions(scenario.variable); + ctx.$rootScope.$digest(); + }); + + fn(scenario); + }); + } + + describeUpdateVariable('interval variable without auto', scenario => { + scenario.setup(() => { + scenario.variableModel = {type: 'interval', query: '1s,2h,5h,1d', name: 'test'}; + }); + + it('should update options array', () => { + expect(scenario.variable.options.length).to.be(4); + expect(scenario.variable.options[0].text).to.be('1s'); + expect(scenario.variable.options[0].value).to.be('1s'); + }); + }); + + // + // Interval variable update + // + describeUpdateVariable('interval variable with auto', scenario => { + scenario.setup(() => { + scenario.variableModel = {type: 'interval', query: '1s,2h,5h,1d', name: 'test', auto: true, auto_count: 10 }; + + var range = { + from: moment(new Date()).subtract(7, 'days').toDate(), + to: new Date() + }; + + ctx.timeSrv.timeRange = sinon.stub().returns(range); + ctx.templateSrv.setGrafanaVariable = sinon.spy(); + }); + + it('should update options array', function() { + expect(scenario.variable.options.length).to.be(5); + expect(scenario.variable.options[0].text).to.be('auto'); + expect(scenario.variable.options[0].value).to.be('$__auto_interval'); + }); + + it('should set $__auto_interval', function() { + var call = ctx.templateSrv.setGrafanaVariable.getCall(0); + expect(call.args[0]).to.be('$__auto_interval'); + expect(call.args[1]).to.be('12h'); + }); + }); + + // + // Query variable update + // + describeUpdateVariable('query variable with empty current object and refresh', function(scenario) { + scenario.setup(function() { + scenario.variableModel = {type: 'query', query: '', name: 'test', current: {}}; + scenario.queryResult = [{text: 'backend1'}, {text: 'backend2'}]; + }); + + it('should set current value to first option', function() { + expect(scenario.variable.options.length).to.be(2); + expect(scenario.variable.current.value).to.be('backend1'); + }); + }); + + describeUpdateVariable('query variable with multi select and new options does not contain some selected values', function(scenario) { + scenario.setup(function() { + scenario.variableModel = { + type: 'query', + query: '', + name: 'test', + current: { + value: ['val1', 'val2', 'val3'], + text: 'val1 + val2 + val3' + } + }; + scenario.queryResult = [{text: 'val2'}, {text: 'val3'}]; + }); + + it('should update current value', function() { + expect(scenario.variable.current.value).to.eql(['val2', 'val3']); + expect(scenario.variable.current.text).to.eql('val2 + val3'); + }); + }); + + describeUpdateVariable('query variable with multi select and new options does not contain any selected values', function(scenario) { + scenario.setup(function() { + scenario.variableModel = { + type: 'query', + query: '', + name: 'test', + current: { + value: ['val1', 'val2', 'val3'], + text: 'val1 + val2 + val3' + } + }; + scenario.queryResult = [{text: 'val5'}, {text: 'val6'}]; + }); + + it('should update current value with first one', function() { + expect(scenario.variable.current.value).to.eql('val5'); + expect(scenario.variable.current.text).to.eql('val5'); + }); + }); + + describeUpdateVariable('query variable with multi select and $__all selected', function(scenario) { + scenario.setup(function() { + scenario.variableModel = { + type: 'query', + query: '', + name: 'test', + includeAll: true, + current: { + value: ['$__all'], + text: 'All' + } + }; + scenario.queryResult = [{text: 'val5'}, {text: 'val6'}]; + }); + + it('should keep current All value', function() { + expect(scenario.variable.current.value).to.eql(['$__all']); + expect(scenario.variable.current.text).to.eql('All'); + }); + }); + + describeUpdateVariable('query variable with numeric results', function(scenario) { + scenario.setup(function() { + scenario.variableModel = { type: 'query', query: '', name: 'test', current: {} }; + scenario.queryResult = [{text: 12, value: 12}]; + }); + + it('should set current value to first option', function() { + expect(scenario.variable.current.value).to.be('12'); + expect(scenario.variable.options[0].value).to.be('12'); + expect(scenario.variable.options[0].text).to.be('12'); + }); + }); + + describeUpdateVariable('basic query variable', function(scenario) { + scenario.setup(function() { + scenario.variableModel = { type: 'query', query: 'apps.*', name: 'test' }; + scenario.queryResult = [{text: 'backend1'}, {text: 'backend2'}]; + }); + + it('should update options array', function() { + expect(scenario.variable.options.length).to.be(2); + expect(scenario.variable.options[0].text).to.be('backend1'); + expect(scenario.variable.options[0].value).to.be('backend1'); + expect(scenario.variable.options[1].value).to.be('backend2'); + }); + + it('should select first option as value', function() { + expect(scenario.variable.current.value).to.be('backend1'); + }); + }); + + describeUpdateVariable('and existing value still exists in options', function(scenario) { + scenario.setup(function() { + scenario.variableModel = {type: 'query', query: 'apps.*', name: 'test'}; + scenario.variableModel.current = { value: 'backend2', text: 'backend2'}; + scenario.queryResult = [{text: 'backend1'}, {text: 'backend2'}]; + }); + + it('should keep variable value', function() { + expect(scenario.variable.current.text).to.be('backend2'); + }); + }); + + describeUpdateVariable('and regex pattern exists', function(scenario) { + scenario.setup(function() { + scenario.variableModel = {type: 'query', query: 'apps.*', name: 'test'}; + scenario.variableModel.regex = '/apps.*(backend_[0-9]+)/'; + scenario.queryResult = [{text: 'apps.backend.backend_01.counters.req'}, {text: 'apps.backend.backend_02.counters.req'}]; + }); + + it('should extract and use match group', function() { + expect(scenario.variable.options[0].value).to.be('backend_01'); + }); + }); + + describeUpdateVariable('and regex pattern exists and no match', function(scenario) { + scenario.setup(function() { + scenario.variableModel = {type: 'query', query: 'apps.*', name: 'test'}; + scenario.variableModel.regex = '/apps.*(backendasd[0-9]+)/'; + scenario.queryResult = [{text: 'apps.backend.backend_01.counters.req'}, {text: 'apps.backend.backend_02.counters.req'}]; + }); + + it('should not add non matching items, None option should be added instead', function() { + expect(scenario.variable.options.length).to.be(1); + expect(scenario.variable.options[0].isNone).to.be(true); + }); + }); + + describeUpdateVariable('regex pattern without slashes', function(scenario) { + scenario.setup(function() { + scenario.variableModel = {type: 'query', query: 'apps.*', name: 'test'}; + scenario.variableModel.regex = 'backend_01'; + scenario.queryResult = [{text: 'apps.backend.backend_01.counters.req'}, {text: 'apps.backend.backend_02.counters.req'}]; + }); + + it('should return matches options', function() { + expect(scenario.variable.options.length).to.be(1); + }); + }); + + describeUpdateVariable('regex pattern remove duplicates', function(scenario) { + scenario.setup(function() { + scenario.variableModel = {type: 'query', query: 'apps.*', name: 'test'}; + scenario.variableModel.regex = '/backend_01/'; + scenario.queryResult = [{text: 'apps.backend.backend_01.counters.req'}, {text: 'apps.backend.backend_01.counters.req'}]; + }); + + it('should return matches options', function() { + expect(scenario.variable.options.length).to.be(1); + }); + }); + + describeUpdateVariable('with include All', function(scenario) { + scenario.setup(function() { + scenario.variableModel = {type: 'query', query: 'apps.*', name: 'test', includeAll: true}; + scenario.queryResult = [{text: 'backend1'}, {text: 'backend2'}, { text: 'backend3'}]; + }); + + it('should add All option', function() { + expect(scenario.variable.options[0].text).to.be('All'); + expect(scenario.variable.options[0].value).to.be('$__all'); + }); + }); + + describeUpdateVariable('with include all and custom value', function(scenario) { + scenario.setup(function() { + scenario.variableModel = {type: 'query', query: 'apps.*', name: 'test', includeAll: true, allValue: '*'}; + scenario.queryResult = [{text: 'backend1'}, {text: 'backend2'}, { text: 'backend3'}]; + }); + + it('should add All option with custom value', function() { + expect(scenario.variable.options[0].value).to.be('$__all'); + }); + }); + + describeUpdateVariable('without sort', function(scenario) { + scenario.setup(function() { + scenario.variableModel = {type: 'query', query: 'apps.*', name: 'test', sort: 0}; + scenario.queryResult = [{text: 'bbb2'}, {text: 'aaa10'}, { text: 'ccc3'}]; + }); + + it('should return options without sort', function() { + expect(scenario.variable.options[0].text).to.be('bbb2'); + expect(scenario.variable.options[1].text).to.be('aaa10'); + expect(scenario.variable.options[2].text).to.be('ccc3'); + }); + }); + + describeUpdateVariable('with alphabetical sort (asc)', function(scenario) { + scenario.setup(function() { + scenario.variableModel = {type: 'query', query: 'apps.*', name: 'test', sort: 1}; + scenario.queryResult = [{text: 'bbb2'}, {text: 'aaa10'}, { text: 'ccc3'}]; + }); + + it('should return options with alphabetical sort', function() { + expect(scenario.variable.options[0].text).to.be('aaa10'); + expect(scenario.variable.options[1].text).to.be('bbb2'); + expect(scenario.variable.options[2].text).to.be('ccc3'); + }); + }); + + describeUpdateVariable('with alphabetical sort (desc)', function(scenario) { + scenario.setup(function() { + scenario.variableModel = {type: 'query', query: 'apps.*', name: 'test', sort: 2}; + scenario.queryResult = [{text: 'bbb2'}, {text: 'aaa10'}, { text: 'ccc3'}]; + }); + + it('should return options with alphabetical sort', function() { + expect(scenario.variable.options[0].text).to.be('ccc3'); + expect(scenario.variable.options[1].text).to.be('bbb2'); + expect(scenario.variable.options[2].text).to.be('aaa10'); + }); + }); + + describeUpdateVariable('with numerical sort (asc)', function(scenario) { + scenario.setup(function() { + scenario.variableModel = {type: 'query', query: 'apps.*', name: 'test', sort: 3}; + scenario.queryResult = [{text: 'bbb2'}, {text: 'aaa10'}, { text: 'ccc3'}]; + }); + + it('should return options with numerical sort', function() { + expect(scenario.variable.options[0].text).to.be('bbb2'); + expect(scenario.variable.options[1].text).to.be('ccc3'); + expect(scenario.variable.options[2].text).to.be('aaa10'); + }); + }); + + describeUpdateVariable('with numerical sort (desc)', function(scenario) { + scenario.setup(function() { + scenario.variableModel = {type: 'query', query: 'apps.*', name: 'test', sort: 4}; + scenario.queryResult = [{text: 'bbb2'}, {text: 'aaa10'}, { text: 'ccc3'}]; + }); + + it('should return options with numerical sort', function() { + expect(scenario.variable.options[0].text).to.be('aaa10'); + expect(scenario.variable.options[1].text).to.be('ccc3'); + expect(scenario.variable.options[2].text).to.be('bbb2'); + }); + }); + + // + // datasource variable update + // + describeUpdateVariable('datasource variable with regex filter', function(scenario) { + scenario.setup(function() { + scenario.variableModel = { + type: 'datasource', + query: 'graphite', + name: 'test', + current: {value: 'backend4_pee', text: 'backend4_pee'}, + regex: '/pee$/' + }; + scenario.metricSources = [ + {name: 'backend1', meta: {id: 'influx'}}, + {name: 'backend2_pee', meta: {id: 'graphite'}}, + {name: 'backend3', meta: {id: 'graphite'}}, + {name: 'backend4_pee', meta: {id: 'graphite'}}, + ]; + }); + + it('should set only contain graphite ds and filtered using regex', function() { + expect(scenario.variable.options.length).to.be(2); + expect(scenario.variable.options[0].value).to.be('backend2_pee'); + expect(scenario.variable.options[1].value).to.be('backend4_pee'); + }); + + it('should keep current value if available', function() { + expect(scenario.variable.current.value).to.be('backend4_pee'); + }); + }); + + // + // Custom variable update + // + describeUpdateVariable('update custom variable', function(scenario) { + scenario.setup(function() { + scenario.variableModel = {type: 'custom', query: 'hej, hop, asd', name: 'test'}; + }); + + it('should update options array', function() { + expect(scenario.variable.options.length).to.be(3); + expect(scenario.variable.options[0].text).to.be('hej'); + expect(scenario.variable.options[1].value).to.be('hop'); + }); + }); +}); diff --git a/public/app/features/templating/templateSrv.js b/public/app/features/templating/templateSrv.js index b8d6cbaee2d..f7784e2cb50 100644 --- a/public/app/features/templating/templateSrv.js +++ b/public/app/features/templating/templateSrv.js @@ -1,10 +1,9 @@ define([ 'angular', 'lodash', - './editorCtrl', - './templateValuesSrv', + 'app/core/utils/kbn', ], -function (angular, _) { +function (angular, _, kbn) { 'use strict'; var module = angular.module('grafana.services'); @@ -16,6 +15,7 @@ function (angular, _) { this._index = {}; this._texts = {}; this._grafanaVariables = {}; + this._adhocVariables = {}; this.init = function(variables) { this.variables = variables; @@ -24,19 +24,32 @@ function (angular, _) { this.updateTemplateData = function() { this._index = {}; + this._filters = {}; for (var i = 0; i < this.variables.length; i++) { var variable = this.variables[i]; + + // add adhoc filters to it's own index + if (variable.type === 'adhoc') { + this._adhocVariables[variable.datasource] = variable; + continue; + } + if (!variable.current || !variable.current.isNone && !variable.current.value) { continue; } + this._index[variable.name] = variable; } }; - function regexEscape(value) { - return value.replace(/[\\^$*+?.()|[\]{}\/]/g, '\\$&'); - } + this.getAdhocFilters = function(datasourceName) { + var variable = this._adhocVariables[datasourceName]; + if (variable) { + return variable.filters || []; + } + return []; + }; function luceneEscape(value) { return value.replace(/([\!\*\+\-\=<>\s\&\|\(\)\[\]\{\}\^\~\?\:\\/"])/g, "\\$1"); @@ -63,10 +76,10 @@ function (angular, _) { switch(format) { case "regex": { if (typeof value === 'string') { - return regexEscape(value); + return kbn.regexEscape(value); } - var escapedValues = _.map(value, regexEscape); + var escapedValues = _.map(value, kbn.regexEscape); return '(' + escapedValues.join('|') + ')'; } case "lucene": { @@ -97,17 +110,6 @@ function (angular, _) { return match && (self._index[match[1] || match[2]] !== void 0); }; - this.containsVariable = function(str, variableName) { - if (!str) { - return false; - } - - variableName = regexEscape(variableName); - var findVarRegex = new RegExp('\\$(' + variableName + ')(?:\\W|$)|\\[\\[(' + variableName + ')\\]\\]', 'g'); - var match = findVarRegex.exec(str); - return match !== null; - }; - this.highlightVariablesAsHtml = function(str) { if (!str || !_.isString(str)) { return str; } @@ -196,18 +198,11 @@ function (angular, _) { this.fillVariableValuesForUrl = function(params, scopedVars) { _.each(this.variables, function(variable) { - var current = variable.current; - var value = current.value; - - if (current.text === 'All') { - value = 'All'; - } - if (scopedVars && scopedVars[variable.name] !== void 0) { - value = scopedVars[variable.name].value; + params['var-' + variable.name] = scopedVars[variable.name].value; + } else { + params['var-' + variable.name] = variable.getValueForUrl(); } - - params['var-' + variable.name] = value; }); }; diff --git a/public/app/features/templating/templateValuesSrv.js b/public/app/features/templating/templateValuesSrv.js index 3a7885ad5a0..a3db47fe3a9 100644 --- a/public/app/features/templating/templateValuesSrv.js +++ b/public/app/features/templating/templateValuesSrv.js @@ -166,8 +166,7 @@ function (angular, _, $, kbn) { if (otherVariable === updatedVariable) { return; } - if ((otherVariable.type === "datasource" && - templateSrv.containsVariable(otherVariable.regex, updatedVariable.name)) || + if (templateSrv.containsVariable(otherVariable.regex, updatedVariable.name) || templateSrv.containsVariable(otherVariable.query, updatedVariable.name) || templateSrv.containsVariable(otherVariable.datasource, updatedVariable.name)) { return self.updateOptions(otherVariable); @@ -188,6 +187,12 @@ function (angular, _, $, kbn) { return; } + if (variable.type === 'adhoc') { + variable.current = {}; + variable.options = []; + return; + } + // extract options in comma separated string variable.options = _.map(variable.query.split(/[,]+/), function(text) { return { text: text.trim(), value: text.trim() }; @@ -271,7 +276,7 @@ function (angular, _, $, kbn) { this.validateVariableSelectionState = function(variable) { if (!variable.current) { - if (!variable.options.length) { return; } + if (!variable.options.length) { return $q.when(); } return self.setVariableValue(variable, variable.options[0], false); } diff --git a/public/app/features/templating/variable.ts b/public/app/features/templating/variable.ts new file mode 100644 index 00000000000..3e12b65ec16 --- /dev/null +++ b/public/app/features/templating/variable.ts @@ -0,0 +1,40 @@ +/// + +import _ from 'lodash'; +import kbn from 'app/core/utils/kbn'; + +export interface Variable { + setValue(option); + updateOptions(); + dependsOn(variable); + setValueFromUrl(urlValue); + getValueForUrl(); + getModel(); +} + +export var variableTypes = {}; + +export function assignModelProperties(target, source, defaults) { + _.forEach(defaults, function(value, key) { + target[key] = source[key] === undefined ? value : source[key]; + }); +} + +export function containsVariable(...args: any[]) { + var variableName = args[args.length-1]; + var str = args[0] || ''; + + for (var i = 1; i < args.length-1; i++) { + str += args[i] || ''; + } + + variableName = kbn.regexEscape(variableName); + var findVarRegex = new RegExp('\\$(' + variableName + ')(?:\\W|$)|\\[\\[(' + variableName + ')\\]\\]', 'g'); + var match = findVarRegex.exec(str); + return match !== null; +} + + + + + diff --git a/public/app/features/templating/variable_srv.ts b/public/app/features/templating/variable_srv.ts new file mode 100644 index 00000000000..b7013d517f4 --- /dev/null +++ b/public/app/features/templating/variable_srv.ts @@ -0,0 +1,233 @@ +/// + +import angular from 'angular'; +import _ from 'lodash'; +import coreModule from 'app/core/core_module'; +import {Variable, variableTypes} from './variable'; + +export class VariableSrv { + dashboard: any; + variables: any; + variableLock: any; + + /** @ngInject */ + constructor(private $rootScope, private $q, private $location, private $injector, private templateSrv) { + // update time variant variables + $rootScope.$on('refresh', this.onDashboardRefresh.bind(this), $rootScope); + $rootScope.$on('template-variable-value-updated', this.updateUrlParamsWithCurrentVariables.bind(this), $rootScope); + } + + init(dashboard) { + this.variableLock = {}; + this.dashboard = dashboard; + + // create working class models representing variables + this.variables = dashboard.templating.list.map(this.createVariableFromModel.bind(this)); + this.templateSrv.init(this.variables); + + // register event to sync back to persisted model + this.dashboard.events.on('prepare-save-model', this.syncToDashboardModel.bind(this)); + + // init variables + for (let variable of this.variables) { + this.variableLock[variable.name] = this.$q.defer(); + } + + var queryParams = this.$location.search(); + return this.$q.all(this.variables.map(variable => { + return this.processVariable(variable, queryParams); + })); + } + + onDashboardRefresh() { + var promises = this.variables + .filter(variable => variable.refresh === 2) + .map(variable => { + var previousOptions = variable.options.slice(); + + return variable.updateOptions() + .then(this.variableUpdated.bind(this, variable)) + .then(() => { + if (angular.toJson(previousOptions) !== angular.toJson(variable.options)) { + this.$rootScope.$emit('template-variable-value-updated'); + } + }); + }); + + return this.$q.all(promises); + } + + processVariable(variable, queryParams) { + var dependencies = []; + var lock = this.variableLock[variable.name]; + + for (let otherVariable of this.variables) { + if (variable.dependsOn(otherVariable)) { + dependencies.push(this.variableLock[otherVariable.name].promise); + } + } + + return this.$q.all(dependencies).then(() => { + var urlValue = queryParams['var-' + variable.name]; + if (urlValue !== void 0) { + return variable.setValueFromUrl(urlValue).then(lock.resolve); + } + + if (variable.refresh === 1 || variable.refresh === 2) { + return variable.updateOptions().then(lock.resolve); + } + + lock.resolve(); + }).finally(() => { + delete this.variableLock[variable.name]; + }); + } + + createVariableFromModel(model) { + var ctor = variableTypes[model.type].ctor; + if (!ctor) { + throw "Unable to find variable constructor for " + model.type; + } + + var variable = this.$injector.instantiate(ctor, {model: model}); + return variable; + } + + addVariable(model) { + var variable = this.createVariableFromModel(model); + this.variables.push(this.createVariableFromModel(variable)); + return variable; + } + + syncToDashboardModel() { + this.dashboard.templating.list = this.variables.map(variable => { + return variable.getModel(); + }); + } + + updateOptions(variable) { + return variable.updateOptions(); + } + + variableUpdated(variable) { + // if there is a variable lock ignore cascading update because we are in a boot up scenario + if (this.variableLock[variable.name]) { + return this.$q.when(); + } + + // cascade updates to variables that use this variable + var promises = _.map(this.variables, otherVariable => { + if (otherVariable === variable) { + return; + } + + if (otherVariable.dependsOn(variable)) { + return this.updateOptions(otherVariable); + } + }); + + return this.$q.all(promises); + } + + selectOptionsForCurrentValue(variable) { + var i, y, value, option; + var selected: any = []; + + for (i = 0; i < variable.options.length; i++) { + option = variable.options[i]; + option.selected = false; + if (_.isArray(variable.current.value)) { + for (y = 0; y < variable.current.value.length; y++) { + value = variable.current.value[y]; + if (option.value === value) { + option.selected = true; + selected.push(option); + } + } + } else if (option.value === variable.current.value) { + option.selected = true; + selected.push(option); + } + } + + return selected; + } + + validateVariableSelectionState(variable) { + if (!variable.current) { + if (!variable.options.length) { return this.$q.when(); } + return variable.setValue(variable.options[0]); + } + + if (_.isArray(variable.current.value)) { + var selected = this.selectOptionsForCurrentValue(variable); + + // if none pick first + if (selected.length === 0) { + selected = variable.options[0]; + } else { + selected = { + value: _.map(selected, function(val) {return val.value;}), + text: _.map(selected, function(val) {return val.text;}).join(' + '), + }; + } + + return variable.setValue(selected); + } else { + var currentOption = _.find(variable.options, {text: variable.current.text}); + if (currentOption) { + return variable.setValue(currentOption); + } else { + if (!variable.options.length) { return Promise.resolve(); } + return variable.setValue(variable.options[0]); + } + } + } + + setOptionFromUrl(variable, urlValue) { + var promise = this.$q.when(); + + if (variable.refresh) { + promise = variable.updateOptions(); + } + + return promise.then(() => { + var option = _.find(variable.options, op => { + return op.text === urlValue || op.value === urlValue; + }); + + option = option || {text: urlValue, value: urlValue}; + return variable.setValue(option); + }); + } + + setOptionAsCurrent(variable, option) { + variable.current = _.cloneDeep(option); + + if (_.isArray(variable.current.text)) { + variable.current.text = variable.current.text.join(' + '); + } + + this.selectOptionsForCurrentValue(variable); + return this.variableUpdated(variable); + } + + updateUrlParamsWithCurrentVariables() { + // update url + var params = this.$location.search(); + + // remove variable params + _.each(params, function(value, key) { + if (key.indexOf('var-') === 0) { + delete params[key]; + } + }); + + // add new values + this.templateSrv.fillVariableValuesForUrl(params); + // update url + this.$location.search(params); + } +} + +coreModule.service('variableSrv', VariableSrv); diff --git a/public/app/partials/valueSelectDropdown.html b/public/app/partials/valueSelectDropdown.html index a6799c80fd0..d1ebce44040 100644 --- a/public/app/partials/valueSelectDropdown.html +++ b/public/app/partials/valueSelectDropdown.html @@ -1,5 +1,5 @@