diff --git a/docs/sources/tutorials/api_org_token_howto.md b/docs/sources/tutorials/api_org_token_howto.md new file mode 100644 index 00000000000..e985b499dbe --- /dev/null +++ b/docs/sources/tutorials/api_org_token_howto.md @@ -0,0 +1,74 @@ ++++ +title = "API Tutorial: How To Create API Tokens And Dashboards For A Specific Organization" +type = "docs" +keywords = ["grafana", "tutorials", "API", "Token", "Org", "Organization"] +[menu.docs] +parent = "tutorials" +weight = 10 ++++ + +# API Tutorial: How To Create API Tokens And Dashboards For A Specific Organization + +A common scenario is to want to via the Grafana API setup new Grafana organizations or to add dynamically generated dashboards to an existing organization. + +## Authentication + +There are two ways to authenticate against the API: basic authentication and API Tokens. + +Some parts of the API are only available through basic authentication and these parts of the API usually require that the user is a Grafana Admin. But all organization actions are accessed via an API Token. An API Token is tied to an organization and can be used to create dashboards etc but only for that organization. + +## How To Create A New Organization and an API Token + +The task is to create a new organization and then add a Token that can be used by other users. In the examples below which use basic auth, the user is `admin` and the password is `admin`. + +1. [Create the org](http://docs.grafana.org/http_api/org/#create-organisation). Here is an example using curl: + ``` + curl -X POST -H "Content-Type: application/json" -d '{"name":"apiorg"}' http://admin:admin@localhost:3000/api/orgs + ``` + + This should return a response: `{"message":"Organization created","orgId":6}`. Use the orgId for the next steps. + +2. Optional step. If the org was created previously and/or step 3 fails then first [add your Admin user to the org](http://docs.grafana.org/http_api/org/#add-user-in-organisation): + ``` + curl -X POST -H "Content-Type: application/json" -d '{"loginOrEmail":"admin", "role": "Admin"}' http://admin:admin@localhost:3000/api/orgs//users + ``` + +3. [Switch the org context for the Admin user to the new org](http://docs.grafana.org/http_api/user/#switch-user-context): + ``` + curl -X POST http://admin:admin@localhost:3000/api/user/using/ + ``` + +4. [Create the API token](http://docs.grafana.org/http_api/auth/#create-api-key): + ``` + curl -X POST -H "Content-Type: application/json" -d '{"name":"apikeycurl", "role": "Admin"}' http://admin:admin@localhost:3000/api/auth/keys + ``` + + This should return a response: `{"name":"apikeycurl","key":"eyJrIjoiR0ZXZmt1UFc0OEpIOGN5RWdUalBJTllUTk83VlhtVGwiLCJuIjoiYXBpa2V5Y3VybCIsImlkIjo2fQ=="}`. + + Save the key returned here in your password manager as it is not possible to fetch again it in the future. + +## How To Add A Dashboard + +Using the Token that was created in the previous step, you can create a dashboard or carry out other actions without having to switch organizations. + +1. [Add a dashboard](http://docs.grafana.org/http_api/dashboard/#create-update-dashboard) using the key (or bearer token as it is also called): + + ``` + curl -X POST --insecure -H "Authorization: Bearer eyJrIjoiR0ZXZmt1UFc0OEpIOGN5RWdUalBJTllUTk83VlhtVGwiLCJuIjoiYXBpa2V5Y3VybCIsImlkIjo2fQ==" -H "Content-Type: application/json" -d '{ + "dashboard": { + "id": null, + "title": "Production Overview", + "tags": [ "templated" ], + "timezone": "browser", + "rows": [ + { + } + ], + "schemaVersion": 6, + "version": 0 + }, + "overwrite": false + }' http://localhost:3000/api/dashboards/db + ``` + + This import will not work if you exported the dashboard via the Share -> Export menu in the Grafana UI (it strips out data source names etc.). View the JSON and save it to a file instead or fetch the dashboard JSON via the API. diff --git a/pkg/api/http_server.go b/pkg/api/http_server.go index 1e143ef876f..0468b5cbe8a 100644 --- a/pkg/api/http_server.go +++ b/pkg/api/http_server.go @@ -61,7 +61,7 @@ func (hs *HttpServer) Start(ctx context.Context) error { return nil } case setting.HTTPS: - err = hs.httpSrv.ListenAndServeTLS(setting.CertFile, setting.KeyFile) + err = hs.listenAndServeTLS(setting.CertFile, setting.KeyFile) if err == http.ErrServerClosed { hs.log.Debug("server was shutdown gracefully") return nil @@ -92,7 +92,7 @@ func (hs *HttpServer) Shutdown(ctx context.Context) error { return err } -func (hs *HttpServer) listenAndServeTLS(listenAddr, certfile, keyfile string) error { +func (hs *HttpServer) listenAndServeTLS(certfile, keyfile string) error { if certfile == "" { return fmt.Errorf("cert_file cannot be empty when using HTTPS") } @@ -127,14 +127,11 @@ func (hs *HttpServer) listenAndServeTLS(listenAddr, certfile, keyfile string) er tls.TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384, }, } - srv := &http.Server{ - Addr: listenAddr, - Handler: hs.macaron, - TLSConfig: tlsCfg, - TLSNextProto: make(map[string]func(*http.Server, *tls.Conn, http.Handler), 0), - } - return srv.ListenAndServeTLS(setting.CertFile, setting.KeyFile) + hs.httpSrv.TLSConfig = tlsCfg + hs.httpSrv.TLSNextProto = make(map[string]func(*http.Server, *tls.Conn, http.Handler), 0) + + return hs.httpSrv.ListenAndServeTLS(setting.CertFile, setting.KeyFile) } func (hs *HttpServer) newMacaron() *macaron.Macaron { diff --git a/public/app/core/components/form_dropdown/form_dropdown.ts b/public/app/core/components/form_dropdown/form_dropdown.ts new file mode 100644 index 00000000000..d8212e2f55c --- /dev/null +++ b/public/app/core/components/form_dropdown/form_dropdown.ts @@ -0,0 +1,250 @@ +/// + +import config from 'app/core/config'; +import _ from 'lodash'; +import $ from 'jquery'; +import coreModule from '../../core_module'; + +function typeaheadMatcher(item) { + var str = this.query; + if (str[0] === '/') { str = str.substring(1); } + if (str[str.length - 1] === '/') { str = str.substring(0, str.length-1); } + return item.toLowerCase().match(str.toLowerCase()); +} + +export class FormDropdownCtrl { + inputElement: any; + linkElement: any; + model: any; + display: any; + text: any; + options: any; + cssClass: any; + cssClasses: any; + allowCustom: any; + labelMode: boolean; + linkMode: boolean; + cancelBlur: any; + onChange: any; + getOptions: any; + optionCache: any; + lookupText: boolean; + + constructor(private $scope, $element, private $sce, private templateSrv, private $q) { + this.inputElement = $element.find('input').first(); + this.linkElement = $element.find('a').first(); + this.linkMode = true; + this.cancelBlur = null; + + // listen to model changes + $scope.$watch("ctrl.model", this.modelChanged.bind(this)); + + if (this.labelMode) { + this.cssClasses = 'gf-form-label ' + this.cssClass; + } else { + this.cssClasses = 'gf-form-input gf-form-input--dropdown ' + this.cssClass; + } + + this.inputElement.attr('data-provide', 'typeahead'); + this.inputElement.typeahead({ + source: this.typeaheadSource.bind(this), + minLength: 0, + items: 10000, + updater: this.typeaheadUpdater.bind(this), + matcher: typeaheadMatcher, + }); + + // modify typeahead lookup + // this = typeahead + var typeahead = this.inputElement.data('typeahead'); + typeahead.lookup = function () { + this.query = this.$element.val() || ''; + var items = this.source(this.query, $.proxy(this.process, this)); + return items ? this.process(items) : items; + }; + + this.linkElement.keydown(evt => { + // trigger typeahead on down arrow or enter key + if (evt.keyCode === 40 || evt.keyCode === 13) { + this.linkElement.click(); + } + }); + + this.inputElement.keydown(evt => { + if (evt.keyCode === 13) { + setTimeout(() => { + this.inputElement.blur(); + }, 100); + } + }); + + this.inputElement.blur(this.inputBlur.bind(this)); + } + + getOptionsInternal(query) { + var result = this.getOptions({$query: query}); + if (this.isPromiseLike(result)) { + return result; + } + return this.$q.when(result); + } + + isPromiseLike(obj) { + return obj && (typeof obj.then === 'function'); + } + + modelChanged() { + if (_.isObject(this.model)) { + this.updateDisplay(this.model.text); + } else { + // if we have text use it + if (this.lookupText) { + this.getOptionsInternal("").then(options => { + var item = _.find(options, {value: this.model}); + this.updateDisplay(item ? item.text : this.model); + }); + } else { + this.updateDisplay(this.model); + } + } + } + + typeaheadSource(query, callback) { + this.getOptionsInternal(query).then(options => { + this.optionCache = options; + + // extract texts + let optionTexts = _.map(options, 'text'); + + // add custom values + if (this.allowCustom) { + if (_.indexOf(optionTexts, this.text) === -1) { + options.unshift(this.text); + } + } + + callback(optionTexts); + }); + } + + typeaheadUpdater(text) { + if (text === this.text) { + clearTimeout(this.cancelBlur); + this.inputElement.focus(); + return text; + } + + this.inputElement.val(text); + this.switchToLink(true); + return text; + } + + switchToLink(fromClick) { + if (this.linkMode && !fromClick) { return; } + + clearTimeout(this.cancelBlur); + this.cancelBlur = null; + this.linkMode = true; + this.inputElement.hide(); + this.linkElement.show(); + this.updateValue(this.inputElement.val()); + } + + inputBlur() { + // happens long before the click event on the typeahead options + // need to have long delay because the blur + this.cancelBlur = setTimeout(this.switchToLink.bind(this), 200); + } + + updateValue(text) { + if (text === '' || this.text === text) { + return; + } + + this.$scope.$apply(() => { + var option = _.find(this.optionCache, {text: text}); + + if (option) { + if (_.isObject(this.model)) { + this.model = option; + } else { + this.model = option.value; + } + this.text = option.text; + } else if (this.allowCustom) { + if (_.isObject(this.model)) { + this.model.text = this.model.value = text; + } else { + this.model = text; + } + this.text = text; + } + + // needs to call this after digest so + // property is synced with outerscope + this.$scope.$$postDigest(() => { + this.$scope.$apply(() => { + this.onChange({$option: option}); + }); + }); + + }); + } + + updateDisplay(text) { + this.text = text; + this.display = this.$sce.trustAsHtml(this.templateSrv.highlightVariablesAsHtml(text)); + } + + open() { + this.inputElement.show(); + + this.inputElement.css('width', (Math.max(this.linkElement.width(), 80) + 16) + 'px'); + this.inputElement.focus(); + + this.linkElement.hide(); + this.linkMode = false; + + var typeahead = this.inputElement.data('typeahead'); + if (typeahead) { + this.inputElement.val(''); + typeahead.lookup(); + } + } +} + +const template = ` + + + + +`; + +export function formDropdownDirective() { + return { + restrict: 'E', + template: template, + controller: FormDropdownCtrl, + bindToController: true, + controllerAs: 'ctrl', + scope: { + model: "=", + getOptions: "&", + onChange: "&", + cssClass: "@", + allowCustom: "@", + labelMode: "@", + lookupText: "@", + }, + }; +} + +coreModule.directive('gfFormDropdown', formDropdownDirective); diff --git a/public/app/core/components/navbar/navbar.html b/public/app/core/components/navbar/navbar.html index ea8eb21d6d6..085e2eef920 100644 --- a/public/app/core/components/navbar/navbar.html +++ b/public/app/core/components/navbar/navbar.html @@ -8,9 +8,9 @@ - - - + + +
diff --git a/public/app/core/core.ts b/public/app/core/core.ts index ede1e819c1a..3f7867f41fa 100644 --- a/public/app/core/core.ts +++ b/public/app/core/core.ts @@ -34,6 +34,7 @@ import {switchDirective} from './components/switch'; import {dashboardSelector} from './components/dashboard_selector'; import {queryPartEditorDirective} from './components/query_part/query_part_editor'; import {WizardFlow} from './components/wizard/wizard'; +import {formDropdownDirective} from './components/form_dropdown/form_dropdown'; import 'app/core/controllers/all'; import 'app/core/services/all'; import 'app/core/routes/routes'; @@ -68,6 +69,7 @@ export { queryPartEditorDirective, WizardFlow, colors, + formDropdownDirective, assignModelProperties, contextSrv, KeybindingSrv, diff --git a/public/app/core/directives/dash_edit_link.js b/public/app/core/directives/dash_edit_link.js index 3e4bdd4c5c7..a4c1ad53b3c 100644 --- a/public/app/core/directives/dash_edit_link.js +++ b/public/app/core/directives/dash_edit_link.js @@ -35,7 +35,7 @@ function ($, angular, coreModule) { options.html = editViewMap[options.editview].html; } - if (lastEditView === options.editview) { + if (lastEditView && lastEditView === options.editview) { hideEditorPane(false); return; } diff --git a/public/app/core/utils/kbn.js b/public/app/core/utils/kbn.js index d619d9c4977..6f8340d5940 100644 --- a/public/app/core/utils/kbn.js +++ b/public/app/core/utils/kbn.js @@ -841,7 +841,7 @@ function($, _) { { text: 'temperature', submenu: [ - {text: 'Celcius (°C)', value: 'celsius' }, + {text: 'Celsius (°C)', value: 'celsius' }, {text: 'Farenheit (°F)', value: 'farenheit' }, {text: 'Kelvin (K)', value: 'kelvin' }, ] diff --git a/public/app/features/dashboard/dashnav/dashnav.html b/public/app/features/dashboard/dashnav/dashnav.html index f1615a0bd1c..bab27289aca 100644 --- a/public/app/features/dashboard/dashnav/dashnav.html +++ b/public/app/features/dashboard/dashnav/dashnav.html @@ -1,63 +1,95 @@ - - - - -
+ + + diff --git a/public/app/features/dashboard/dashnav/dashnav.ts b/public/app/features/dashboard/dashnav/dashnav.ts index e77c2bb3e09..98cefa46c7f 100644 --- a/public/app/features/dashboard/dashnav/dashnav.ts +++ b/public/app/features/dashboard/dashnav/dashnav.ts @@ -22,8 +22,8 @@ export class DashNavCtrl { private backendSrv, private $timeout, private datasourceSrv, - private navModelSrv) { - + private navModelSrv, + private contextSrv) { this.navModel = navModelSrv.getDashboardNav(this.dashboard, this); appEvents.on('save-dashboard', this.saveDashboard.bind(this), $scope); @@ -38,6 +38,10 @@ export class DashNavCtrl { } } + toggleSideMenu() { + this.contextSrv.toggleSideMenu(); + } + openEditView(editview) { var search = _.extend(this.$location.search(), {editview: editview}); this.$location.search(search); @@ -135,6 +139,17 @@ export class DashNavCtrl { var uri = "data:application/json;charset=utf-8," + encodeURIComponent(html); var newWindow = window.open(uri); } + + showSearch() { + this.$rootScope.appEvent('show-dash-search'); + } + + navItemClicked(navItem, evt) { + if (navItem.clickHandler) { + navItem.clickHandler(); + evt.preventDefault(); + } + } } export function dashNavDirective() { diff --git a/public/app/features/panel/metrics_tab.ts b/public/app/features/panel/metrics_tab.ts index f2d99738040..c03c4d83bc2 100644 --- a/public/app/features/panel/metrics_tab.ts +++ b/public/app/features/panel/metrics_tab.ts @@ -5,8 +5,6 @@ import _ from 'lodash'; import {DashboardModel} from '../dashboard/model'; export class MetricsTabCtrl { - dsSegment: any; - mixedDsSegment: any; dsName: string; panel: any; panelCtrl: any; @@ -14,30 +12,26 @@ export class MetricsTabCtrl { current: any; nextRefId: string; dashboard: DashboardModel; + panelDsValue: any; + addQueryDropdown: any; /** @ngInject */ - constructor($scope, private uiSegmentSrv, datasourceSrv) { + constructor($scope, private uiSegmentSrv, private datasourceSrv) { this.panelCtrl = $scope.ctrl; $scope.ctrl = this; this.panel = this.panelCtrl.panel; this.dashboard = this.panelCtrl.dashboard; this.datasources = datasourceSrv.getMetricSources(); - - var dsValue = this.panelCtrl.panel.datasource || null; + this.panelDsValue = this.panelCtrl.panel.datasource || null; for (let ds of this.datasources) { - if (ds.value === dsValue) { + if (ds.value === this.panelDsValue) { this.current = ds; } } - if (!this.current) { - this.current = {name: dsValue + ' not found', value: null}; - } - - this.dsSegment = uiSegmentSrv.newSegment({value: this.current.name, selectMode: true}); - this.mixedDsSegment = uiSegmentSrv.newSegment({value: 'Add Query', selectMode: true, fake: true}); + this.addQueryDropdown = {text: 'Add Query', value: null, fake: true}; // update next ref id this.panelCtrl.nextRefId = this.dashboard.getNextQueryLetter(this.panel); @@ -46,33 +40,28 @@ export class MetricsTabCtrl { getOptions(includeBuiltin) { return Promise.resolve(this.datasources.filter(value => { return includeBuiltin || !value.meta.builtIn; - }).map(value => { - return this.uiSegmentSrv.newSegment(value.name); + }).map(ds => { + return {value: ds.value, text: ds.name, datasource: ds}; })); } - datasourceChanged() { - var ds = _.find(this.datasources, {name: this.dsSegment.value}); - if (ds) { - this.current = ds; - this.panelCtrl.setDatasource(ds); + datasourceChanged(option) { + if (!option) { + return; } + + this.current = option.datasource; + this.panelCtrl.setDatasource(option.datasource); } - mixedDatasourceChanged() { - var target: any = {isNew: true}; - var ds = _.find(this.datasources, {name: this.mixedDsSegment.value}); - - if (ds) { - target.datasource = ds.name; - this.panelCtrl.addQuery(target); + addMixedQuery(option) { + if (!option) { + return; } - // metric segments are really bad, requires hacks to update - const segment = this.uiSegmentSrv.newSegment({value: 'Add Query', selectMode: true, fake: true}); - this.mixedDsSegment.value = segment.value; - this.mixedDsSegment.html = segment.html; - this.mixedDsSegment.text = segment.text; + var target: any = {isNew: true}; + this.panelCtrl.addQuery({isNew: true, datasource: option.datasource.name}); + this.addQueryDropdown = {text: 'Add Query', value: null, fake: true}; } addQuery() { diff --git a/public/app/features/panel/partials/metrics_tab.html b/public/app/features/panel/partials/metrics_tab.html index a020a2edbd1..bc0bcf7c6b2 100644 --- a/public/app/features/panel/partials/metrics_tab.html +++ b/public/app/features/panel/partials/metrics_tab.html @@ -19,7 +19,10 @@ @@ -30,10 +33,12 @@
- - + + +
diff --git a/public/app/plugins/datasource/elasticsearch/bucket_agg.js b/public/app/plugins/datasource/elasticsearch/bucket_agg.js index b2cfc819579..5adaed173af 100644 --- a/public/app/plugins/datasource/elasticsearch/bucket_agg.js +++ b/public/app/plugins/datasource/elasticsearch/bucket_agg.js @@ -26,13 +26,21 @@ function (angular, _, queryDef) { var bucketAggs = $scope.target.bucketAggs; $scope.orderByOptions = []; - $scope.bucketAggTypes = queryDef.bucketAggTypes; - $scope.orderOptions = queryDef.orderOptions; - $scope.sizeOptions = queryDef.sizeOptions; + + $scope.getBucketAggTypes = function() { + return queryDef.bucketAggTypes; + }; + + $scope.getOrderOptions = function() { + return queryDef.orderOptions; + }; + + $scope.getSizeOptions = function() { + return queryDef.sizeOptions; + }; $rootScope.onAppEvent('elastic-query-updated', function() { $scope.validateModel(); - $scope.updateOrderByOptions(); }, $scope); $scope.init = function() { @@ -166,11 +174,10 @@ function (angular, _, queryDef) { $scope.toggleOptions = function() { $scope.showOptions = !$scope.showOptions; - $scope.updateOrderByOptions(); }; - $scope.updateOrderByOptions = function() { - $scope.orderByOptions = queryDef.getOrderByOptions($scope.target); + $scope.getOrderByOptions = function() { + return queryDef.getOrderByOptions($scope.target); }; $scope.getFieldsInternal = function() { diff --git a/public/app/plugins/datasource/elasticsearch/partials/bucket_agg.html b/public/app/plugins/datasource/elasticsearch/partials/bucket_agg.html index 8c14f3ef356..a180f97c994 100644 --- a/public/app/plugins/datasource/elasticsearch/partials/bucket_agg.html +++ b/public/app/plugins/datasource/elasticsearch/partials/bucket_agg.html @@ -5,8 +5,22 @@ Then by - - + + + +
@@ -33,7 +47,13 @@
- + +
@@ -66,11 +86,23 @@
- + +
- + +
@@ -78,7 +110,13 @@
- + +