diff --git a/.bowerrc b/.bowerrc new file mode 100644 index 00000000000..36643c6062c --- /dev/null +++ b/.bowerrc @@ -0,0 +1,3 @@ +{ + "directory": "public/vendor/" +} diff --git a/.gitignore b/.gitignore index c3eb5d8862c..99ed4c4adfd 100644 --- a/.gitignore +++ b/.gitignore @@ -26,4 +26,5 @@ public/css/*.min.css conf/custom.ini fig.yml +profile.cov diff --git a/CHANGELOG.md b/CHANGELOG.md index 94852d43047..c046d3364cc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,52 @@ +# 2.1.0 (unreleased - master branch) + +**Data sources** +- [Issue #1525](https://github.com/grafana/grafana/issues/1525). InfluxDB: Full support for InfluxDB 0.9 with new adapted query editor +- [Issue #2191](https://github.com/grafana/grafana/issues/2191). KariosDB: Grafana now ships with a KariosDB data source plugin, thx @masaori335 +- [Issue #1177](https://github.com/grafana/grafana/issues/1177). OpenTSDB: Limit tags by metric, OpenTSDB config option tsd.core.meta.enable_realtime_ts must enabled for OpenTSDB lookup api +- [Issue #1250](https://github.com/grafana/grafana/issues/1250). OpenTSDB: Support for template variable values lookup queries + +**New dashboard features** +- [Issue #1144](https://github.com/grafana/grafana/issues/1144). Templating: You can now select multiple template variables values at the same time. +- [Issue #1922](https://github.com/grafana/grafana/issues/1922). Templating: Specify multiple variable values via URL params. +- [Issue #1888](https://github.com/grafana/grafana/issues/1144). Templating: Repeat panel or row for each selected template variable value +- [Issue #1888](https://github.com/grafana/grafana/issues/1944). Dashboard: Custom Navigation links & dynamic links to related dashboards +- [Issue #590](https://github.com/grafana/grafana/issues/590). Graph: Define series color using regex rule +- [Issue #2162](https://github.com/grafana/grafana/issues/2162). Graph: New series style override, negative-y transform and stack groups +- [Issue #2096](https://github.com/grafana/grafana/issues/2096). Dashboard list panel: Now supports search by multiple tags +- [Issue #2203](https://github.com/grafana/grafana/issues/2203). Singlestat: Now support string values + +**User or Organization admin** +- [Issue #1899](https://github.com/grafana/grafana/issues/1899). Organization: You can now update the organization user role directly (without removing and readding the organization user). +- [Issue #2088](https://github.com/grafana/grafana/issues/2088). Roles: New user role `Read Only Editor` that replaces the old `Viewer` role behavior + +**Backend** +- [Issue #2218](https://github.com/grafana/grafana/issues/2218). Auth: You can now authenicate against api with username / password using basic auth +- [Issue #2095](https://github.com/grafana/grafana/issues/2095). Search: Search now supports filtering by multiple dashboard tags +- [Issue #1905](https://github.com/grafana/grafana/issues/1905). Github OAuth: You can now configure a Github team membership requirement, thx @dewski +- [Issue #2052](https://github.com/grafana/grafana/issues/2052). Github OAuth: You can now configure a Github organization requirement, thx @indrekj +- [Issue #1891](https://github.com/grafana/grafana/issues/1891). Security: New config option to disable the use of gravatar for profile images +- [Issue #1921](https://github.com/grafana/grafana/issues/1921). Auth: Support for user authentication via reverse proxy header (like X-Authenticated-User, or X-WEBAUTH-USER) +- [Issue #960](https://github.com/grafana/grafana/issues/960). Search: Backend can now index a folder with json files, will be available in search (saving back to folder is not supported, this feature is meant for static generated json dashboards) + +**Breaking changes** +- [Issue #1826](https://github.com/grafana/grafana/issues/1826). User role 'Viewer' are now prohibited from entering edit mode (and doing other transient dashboard edits). A new role `Read Only Editor` will replace the old Viewer behavior +- [Issue #1928](https://github.com/grafana/grafana/issues/1928). HTTP API: GET /api/dashboards/db/:slug response changed property `model` to `dashboard` to match the POST request nameing +- Backend render URL changed from `/render/dashboard/solo` `render/dashboard-solo/` (in order to have consistent dashboard url `/dashboard/:type/:slug`) +- Search HTTP API response has changed (simplified), tags list moved to seperate HTTP resource URI +- Datasource HTTP api breaking change, ADD datasource is now POST /api/datasources/, update is now PUT /api/datasources/:id + +**Fixes** +- [Issue #2185](https://github.com/grafana/grafana/issues/2185). Graph: fixed PNG rendering of panels with legend table to the right +- [Issue #2163](https://github.com/grafana/grafana/issues/2163). Backend: Load dashboards with capital letters in the dashboard url slug (url id) + +# 2.0.3 (unreleased - 2.0.x branch) + +**Fixes** +- [Issue #1872](https://github.com/grafana/grafana/issues/1872). Firefox/IE issue, invisible text in dashboard search fixed +- [Issue #1857](https://github.com/grafana/grafana/issues/1857). /api/login/ping Fix for issue when behind reverse proxy and subpath +- [Issue #1863](https://github.com/grafana/grafana/issues/1863). MySQL: Dashboard.data column type changed to mediumtext (sql migration added) + # 2.0.2 (2015-04-22) **Fixes** diff --git a/Godeps/Godeps.json b/Godeps/Godeps.json index a7e150e215e..063f0efedd7 100644 --- a/Godeps/Godeps.json +++ b/Godeps/Godeps.json @@ -29,7 +29,7 @@ }, { "ImportPath": "github.com/gosimple/slug", - "Rev": "a2392a4a87fa0366cbff131d3fd421f83f52492f" + "Rev": "8d258463b4459f161f51d6a357edacd3eef9d663" }, { "ImportPath": "github.com/jtolds/gls", @@ -52,6 +52,10 @@ "ImportPath": "github.com/mattn/go-sqlite3", "Rev": "e28cd440fabdd39b9520344bc26829f61db40ece" }, + { + "ImportPath": "github.com/rainycape/unidecode", + "Rev": "836ef0a715aedf08a12d595ed73ec8ed5b288cac" + }, { "ImportPath": "github.com/smartystreets/goconvey/convey", "Comment": "1.5.0-356-gfbc0a1c", @@ -83,14 +87,6 @@ "ImportPath": "gopkg.in/redis.v2", "Comment": "v2.3.2", "Rev": "e6179049628164864e6e84e973cfb56335748dea" - }, - { - "ImportPath": "gopkgs.com/pool.v1", - "Rev": "c850f092aad1780cbffff25f471c5cc32097932a" - }, - { - "ImportPath": "gopkgs.com/unidecode.v1", - "Rev": "4deae2c05236b41cc39f8144ac87a837ba974d40" } ] } diff --git a/Godeps/_workspace/src/github.com/gosimple/slug/slug.go b/Godeps/_workspace/src/github.com/gosimple/slug/slug.go index 26974fbd7d8..b2c7d62514e 100644 --- a/Godeps/_workspace/src/github.com/gosimple/slug/slug.go +++ b/Godeps/_workspace/src/github.com/gosimple/slug/slug.go @@ -6,9 +6,10 @@ package slug import ( - "gopkgs.com/unidecode.v1" "regexp" "strings" + + "github.com/rainycape/unidecode" ) var ( diff --git a/Godeps/_workspace/src/gopkgs.com/pool.v1/.gitignore b/Godeps/_workspace/src/github.com/rainycape/unidecode/.gitignore similarity index 100% rename from Godeps/_workspace/src/gopkgs.com/pool.v1/.gitignore rename to Godeps/_workspace/src/github.com/rainycape/unidecode/.gitignore diff --git a/Godeps/_workspace/src/gopkgs.com/pool.v1/LICENSE b/Godeps/_workspace/src/github.com/rainycape/unidecode/LICENSE similarity index 100% rename from Godeps/_workspace/src/gopkgs.com/pool.v1/LICENSE rename to Godeps/_workspace/src/github.com/rainycape/unidecode/LICENSE diff --git a/Godeps/_workspace/src/github.com/rainycape/unidecode/README.md b/Godeps/_workspace/src/github.com/rainycape/unidecode/README.md new file mode 100644 index 00000000000..9a109bcfdb2 --- /dev/null +++ b/Godeps/_workspace/src/github.com/rainycape/unidecode/README.md @@ -0,0 +1,6 @@ +unidecode +========= + +Unicode transliterator in Golang - Replaces non-ASCII characters with their ASCII approximations. + +[![GoDoc](https://godoc.org/github.com/rainycape/unidecode?status.svg)](https://godoc.org/github.com/rainycape/unidecode) diff --git a/Godeps/_workspace/src/gopkgs.com/unidecode.v1/decode.go b/Godeps/_workspace/src/github.com/rainycape/unidecode/decode.go similarity index 92% rename from Godeps/_workspace/src/gopkgs.com/unidecode.v1/decode.go rename to Godeps/_workspace/src/github.com/rainycape/unidecode/decode.go index 028533bf425..fe74bf3d880 100644 --- a/Godeps/_workspace/src/gopkgs.com/unidecode.v1/decode.go +++ b/Godeps/_workspace/src/github.com/rainycape/unidecode/decode.go @@ -5,12 +5,9 @@ import ( "encoding/binary" "io" "strings" - "sync" ) var ( - decoded = false - mutex sync.Mutex transliterations [65536][]rune transCount = rune(len(transliterations)) getUint16 = binary.LittleEndian.Uint16 diff --git a/Godeps/_workspace/src/gopkgs.com/unidecode.v1/make_table.go b/Godeps/_workspace/src/github.com/rainycape/unidecode/make_table.go similarity index 100% rename from Godeps/_workspace/src/gopkgs.com/unidecode.v1/make_table.go rename to Godeps/_workspace/src/github.com/rainycape/unidecode/make_table.go diff --git a/Godeps/_workspace/src/gopkgs.com/unidecode.v1/table.go b/Godeps/_workspace/src/github.com/rainycape/unidecode/table.go similarity index 100% rename from Godeps/_workspace/src/gopkgs.com/unidecode.v1/table.go rename to Godeps/_workspace/src/github.com/rainycape/unidecode/table.go diff --git a/Godeps/_workspace/src/gopkgs.com/unidecode.v1/table.txt b/Godeps/_workspace/src/github.com/rainycape/unidecode/table.txt similarity index 100% rename from Godeps/_workspace/src/gopkgs.com/unidecode.v1/table.txt rename to Godeps/_workspace/src/github.com/rainycape/unidecode/table.txt diff --git a/Godeps/_workspace/src/gopkgs.com/unidecode.v1/unidecode.go b/Godeps/_workspace/src/github.com/rainycape/unidecode/unidecode.go similarity index 86% rename from Godeps/_workspace/src/gopkgs.com/unidecode.v1/unidecode.go rename to Godeps/_workspace/src/github.com/rainycape/unidecode/unidecode.go index fa414bb0954..f9d2d49d418 100644 --- a/Godeps/_workspace/src/gopkgs.com/unidecode.v1/unidecode.go +++ b/Godeps/_workspace/src/github.com/rainycape/unidecode/unidecode.go @@ -4,15 +4,15 @@ package unidecode import ( + "sync" "unicode" - - "gopkgs.com/pool.v1" ) const pooledCapacity = 64 var ( - slicePool = pool.New(0) + slicePool sync.Pool + decodingOnce sync.Once ) // Unidecode implements a unicode transliterator, which @@ -23,14 +23,7 @@ var ( // with their closest ASCII counterparts. // e.g. Unicode("áéíóú") => "aeiou" func Unidecode(s string) string { - if !decoded { - mutex.Lock() - if !decoded { - decodeTransliterations() - decoded = true - } - mutex.Unlock() - } + decodingOnce.Do(decodeTransliterations) l := len(s) var r []rune if l > pooledCapacity { diff --git a/Godeps/_workspace/src/gopkgs.com/unidecode.v1/unidecode_test.go b/Godeps/_workspace/src/github.com/rainycape/unidecode/unidecode_test.go similarity index 100% rename from Godeps/_workspace/src/gopkgs.com/unidecode.v1/unidecode_test.go rename to Godeps/_workspace/src/github.com/rainycape/unidecode/unidecode_test.go diff --git a/Godeps/_workspace/src/gopkgs.com/pool.v1/README.md b/Godeps/_workspace/src/gopkgs.com/pool.v1/README.md deleted file mode 100644 index f9c561dc067..00000000000 --- a/Godeps/_workspace/src/gopkgs.com/pool.v1/README.md +++ /dev/null @@ -1,13 +0,0 @@ -pool -==== - -sync.Pool compatibility layer for for Go - falls back to a channel based pool in Go < 1.3 - - -Please, use the following import path to ensure a stable API: - -```go - import "gopkgs.com/pool.v1" -``` - -View other available versions, documentation and examples at http://gopkgs.com/pool diff --git a/Godeps/_workspace/src/gopkgs.com/pool.v1/doc.go b/Godeps/_workspace/src/gopkgs.com/pool.v1/doc.go deleted file mode 100644 index 7546db7ac21..00000000000 --- a/Godeps/_workspace/src/gopkgs.com/pool.v1/doc.go +++ /dev/null @@ -1,3 +0,0 @@ -// Package pool provides a sync.Pool compatibility layer, which -// falls back to a channel based pool on Go < 1.3. -package pool diff --git a/Godeps/_workspace/src/gopkgs.com/pool.v1/example_test.go b/Godeps/_workspace/src/gopkgs.com/pool.v1/example_test.go deleted file mode 100644 index dff9d348b3f..00000000000 --- a/Godeps/_workspace/src/gopkgs.com/pool.v1/example_test.go +++ /dev/null @@ -1,23 +0,0 @@ -package pool_test - -import ( - "fmt" - - "gopkgs.com/pool.v1" -) - -func ExamplePool() { - p := pool.New(0) - p.Put("Hello") - fmt.Println(p.Get()) - // OutPut: Hello -} - -func ExamplePoolNew() { - p := pool.New(0) - p.New = func() interface{} { - return "World!" - } - fmt.Println(p.Get()) - // OutPut: World! -} diff --git a/Godeps/_workspace/src/gopkgs.com/pool.v1/gopkgs.go b/Godeps/_workspace/src/gopkgs.com/pool.v1/gopkgs.go deleted file mode 100644 index 394a97806d6..00000000000 --- a/Godeps/_workspace/src/gopkgs.com/pool.v1/gopkgs.go +++ /dev/null @@ -1,24 +0,0 @@ -package pool - -import ( - "fmt" - "reflect" -) - -// gopkgs.go: v1 - -// NOTE: This file is autogenerated by gopkgs.com. -const ( - goPkgsSrcPath = "github.com/rainycape/pool" - goPkgsName = "pool" - goPkgsErrFmt = "invalid import path %s - please use gopkgs.com/%s.v1 or see http://gopkgs.com/%s" -) - -type goPkgsCheck struct{} - -func init() { - typ := reflect.TypeOf(goPkgsCheck{}) - if typ.PkgPath() == goPkgsSrcPath { - panic(fmt.Errorf(goPkgsErrFmt, typ.PkgPath(), goPkgsName, goPkgsName)) - } -} diff --git a/Godeps/_workspace/src/gopkgs.com/pool.v1/pool.go b/Godeps/_workspace/src/gopkgs.com/pool.v1/pool.go deleted file mode 100644 index 269a2afd919..00000000000 --- a/Godeps/_workspace/src/gopkgs.com/pool.v1/pool.go +++ /dev/null @@ -1,37 +0,0 @@ -// +build go1.3,!appengine - -package pool - -import ( - "sync" -) - -// Pool is a thin compatibility type to allow Go -// libraries to use the new sync.Pool in Go 1.3, -// while remaining compatible with lower Go versions. -// For more information, see the sync.Pool type. -type Pool sync.Pool - -// New returns a new Pool. The size argument is -// ignored on Go >= 1.3. In Go < 1.3, if size is -// zero, it's set to runtime.GOMAXPROCS(0) * 2. -func New(size int) *Pool { - return &Pool{} -} - -// Get returns an arbitrary previously Put value, removing -// it from the pool, or nil if there are no such values. Note -// that callers should not assume anything about the Get return -// value, since the runtime might decide to collect the elements -// from the pool at any time. -// -// If there are no elements to return and the New() field is non-nil, -// Get returns the result of calling it. -func (p *Pool) Get() interface{} { - return (*sync.Pool)(p).Get() -} - -// Put adds x to the pool. -func (p *Pool) Put(x interface{}) { - (*sync.Pool)(p).Put(x) -} diff --git a/Godeps/_workspace/src/gopkgs.com/pool.v1/pool_go1.2.go b/Godeps/_workspace/src/gopkgs.com/pool.v1/pool_go1.2.go deleted file mode 100644 index 11c4e490a30..00000000000 --- a/Godeps/_workspace/src/gopkgs.com/pool.v1/pool_go1.2.go +++ /dev/null @@ -1,57 +0,0 @@ -// +build !go1.3 appengine - -package pool - -import ( - "runtime" -) - -// Pool is a thin compatibility type to allow Go -// libraries to use the new sync.Pool in Go 1.3, -// while remaining compatible with lower Go versions. -// For more information, see the sync.Pool type. -type Pool struct { - ch chan interface{} - // New specifies a function to generate - // a new value, when Get would otherwise - // return nil. - New func() interface{} -} - -// New returns a new Pool. The size argument is -// ignored on Go >= 1.3. In Go < 1.3, if size is -// zero, it's set to runtime.GOMAXPROCS(0) * 2. -func New(size int) *Pool { - if size == 0 { - size = runtime.GOMAXPROCS(0) * 2 - } - return &Pool{ch: make(chan interface{}, size)} -} - -// Get returns an arbitrary previously Put value, removing -// it from the pool, or nil if there are no such values. Note -// that callers should not assume anything about the Get return -// value, since the runtime might decide to collect the elements -// from the pool at any time. -// -// If there are no elements to return and the New() field is non-nil, -// Get returns the result of calling it. -func (p *Pool) Get() interface{} { - select { - case x := <-p.ch: - return x - default: - } - if p.New != nil { - return p.New() - } - return nil -} - -// Put adds x to the pool. -func (p *Pool) Put(x interface{}) { - select { - case p.ch <- x: - default: - } -} diff --git a/Godeps/_workspace/src/gopkgs.com/unidecode.v1/.gitignore b/Godeps/_workspace/src/gopkgs.com/unidecode.v1/.gitignore deleted file mode 100644 index 836562412fe..00000000000 --- a/Godeps/_workspace/src/gopkgs.com/unidecode.v1/.gitignore +++ /dev/null @@ -1,23 +0,0 @@ -# Compiled Object files, Static and Dynamic libs (Shared Objects) -*.o -*.a -*.so - -# Folders -_obj -_test - -# Architecture specific extensions/prefixes -*.[568vq] -[568vq].out - -*.cgo1.go -*.cgo2.c -_cgo_defun.c -_cgo_gotypes.go -_cgo_export.* - -_testmain.go - -*.exe -*.test diff --git a/Godeps/_workspace/src/gopkgs.com/unidecode.v1/LICENSE b/Godeps/_workspace/src/gopkgs.com/unidecode.v1/LICENSE deleted file mode 100644 index ad410e11302..00000000000 --- a/Godeps/_workspace/src/gopkgs.com/unidecode.v1/LICENSE +++ /dev/null @@ -1,201 +0,0 @@ -Apache License - Version 2.0, January 2004 - http://www.apache.org/licenses/ - - TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION - - 1. Definitions. - - "License" shall mean the terms and conditions for use, reproduction, - and distribution as defined by Sections 1 through 9 of this document. - - "Licensor" shall mean the copyright owner or entity authorized by - the copyright owner that is granting the License. - - "Legal Entity" shall mean the union of the acting entity and all - other entities that control, are controlled by, or are under common - control with that entity. For the purposes of this definition, - "control" means (i) the power, direct or indirect, to cause the - direction or management of such entity, whether by contract or - otherwise, or (ii) ownership of fifty percent (50%) or more of the - outstanding shares, or (iii) beneficial ownership of such entity. - - "You" (or "Your") shall mean an individual or Legal Entity - exercising permissions granted by this License. - - "Source" form shall mean the preferred form for making modifications, - including but not limited to software source code, documentation - source, and configuration files. - - "Object" form shall mean any form resulting from mechanical - transformation or translation of a Source form, including but - not limited to compiled object code, generated documentation, - and conversions to other media types. - - "Work" shall mean the work of authorship, whether in Source or - Object form, made available under the License, as indicated by a - copyright notice that is included in or attached to the work - (an example is provided in the Appendix below). - - "Derivative Works" shall mean any work, whether in Source or Object - form, that is based on (or derived from) the Work and for which the - editorial revisions, annotations, elaborations, or other modifications - represent, as a whole, an original work of authorship. For the purposes - of this License, Derivative Works shall not include works that remain - separable from, or merely link (or bind by name) to the interfaces of, - the Work and Derivative Works thereof. - - "Contribution" shall mean any work of authorship, including - the original version of the Work and any modifications or additions - to that Work or Derivative Works thereof, that is intentionally - submitted to Licensor for inclusion in the Work by the copyright owner - or by an individual or Legal Entity authorized to submit on behalf of - the copyright owner. For the purposes of this definition, "submitted" - means any form of electronic, verbal, or written communication sent - to the Licensor or its representatives, including but not limited to - communication on electronic mailing lists, source code control systems, - and issue tracking systems that are managed by, or on behalf of, the - Licensor for the purpose of discussing and improving the Work, but - excluding communication that is conspicuously marked or otherwise - designated in writing by the copyright owner as "Not a Contribution." - - "Contributor" shall mean Licensor and any individual or Legal Entity - on behalf of whom a Contribution has been received by Licensor and - subsequently incorporated within the Work. - - 2. Grant of Copyright License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - copyright license to reproduce, prepare Derivative Works of, - publicly display, publicly perform, sublicense, and distribute the - Work and such Derivative Works in Source or Object form. - - 3. Grant of Patent License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - (except as stated in this section) patent license to make, have made, - use, offer to sell, sell, import, and otherwise transfer the Work, - where such license applies only to those patent claims licensable - by such Contributor that are necessarily infringed by their - Contribution(s) alone or by combination of their Contribution(s) - with the Work to which such Contribution(s) was submitted. If You - institute patent litigation against any entity (including a - cross-claim or counterclaim in a lawsuit) alleging that the Work - or a Contribution incorporated within the Work constitutes direct - or contributory patent infringement, then any patent licenses - granted to You under this License for that Work shall terminate - as of the date such litigation is filed. - - 4. Redistribution. You may reproduce and distribute copies of the - Work or Derivative Works thereof in any medium, with or without - modifications, and in Source or Object form, provided that You - meet the following conditions: - - (a) You must give any other recipients of the Work or - Derivative Works a copy of this License; and - - (b) You must cause any modified files to carry prominent notices - stating that You changed the files; and - - (c) You must retain, in the Source form of any Derivative Works - that You distribute, all copyright, patent, trademark, and - attribution notices from the Source form of the Work, - excluding those notices that do not pertain to any part of - the Derivative Works; and - - (d) If the Work includes a "NOTICE" text file as part of its - distribution, then any Derivative Works that You distribute must - include a readable copy of the attribution notices contained - within such NOTICE file, excluding those notices that do not - pertain to any part of the Derivative Works, in at least one - of the following places: within a NOTICE text file distributed - as part of the Derivative Works; within the Source form or - documentation, if provided along with the Derivative Works; or, - within a display generated by the Derivative Works, if and - wherever such third-party notices normally appear. The contents - of the NOTICE file are for informational purposes only and - do not modify the License. You may add Your own attribution - notices within Derivative Works that You distribute, alongside - or as an addendum to the NOTICE text from the Work, provided - that such additional attribution notices cannot be construed - as modifying the License. - - You may add Your own copyright statement to Your modifications and - may provide additional or different license terms and conditions - for use, reproduction, or distribution of Your modifications, or - for any such Derivative Works as a whole, provided Your use, - reproduction, and distribution of the Work otherwise complies with - the conditions stated in this License. - - 5. Submission of Contributions. Unless You explicitly state otherwise, - any Contribution intentionally submitted for inclusion in the Work - by You to the Licensor shall be under the terms and conditions of - this License, without any additional terms or conditions. - Notwithstanding the above, nothing herein shall supersede or modify - the terms of any separate license agreement you may have executed - with Licensor regarding such Contributions. - - 6. Trademarks. This License does not grant permission to use the trade - names, trademarks, service marks, or product names of the Licensor, - except as required for reasonable and customary use in describing the - origin of the Work and reproducing the content of the NOTICE file. - - 7. Disclaimer of Warranty. Unless required by applicable law or - agreed to in writing, Licensor provides the Work (and each - Contributor provides its Contributions) on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or - implied, including, without limitation, any warranties or conditions - of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A - PARTICULAR PURPOSE. You are solely responsible for determining the - appropriateness of using or redistributing the Work and assume any - risks associated with Your exercise of permissions under this License. - - 8. Limitation of Liability. In no event and under no legal theory, - whether in tort (including negligence), contract, or otherwise, - unless required by applicable law (such as deliberate and grossly - negligent acts) or agreed to in writing, shall any Contributor be - liable to You for damages, including any direct, indirect, special, - incidental, or consequential damages of any character arising as a - result of this License or out of the use or inability to use the - Work (including but not limited to damages for loss of goodwill, - work stoppage, computer failure or malfunction, or any and all - other commercial damages or losses), even if such Contributor - has been advised of the possibility of such damages. - - 9. Accepting Warranty or Additional Liability. While redistributing - the Work or Derivative Works thereof, You may choose to offer, - and charge a fee for, acceptance of support, warranty, indemnity, - or other liability obligations and/or rights consistent with this - License. However, in accepting such obligations, You may act only - on Your own behalf and on Your sole responsibility, not on behalf - of any other Contributor, and only if You agree to indemnify, - defend, and hold each Contributor harmless for any liability - incurred by, or claims asserted against, such Contributor by reason - of your accepting any such warranty or additional liability. - - END OF TERMS AND CONDITIONS - - APPENDIX: How to apply the Apache License to your work. - - To apply the Apache License to your work, attach the following - boilerplate notice, with the fields enclosed by brackets "{}" - replaced with your own identifying information. (Don't include - the brackets!) The text should be enclosed in the appropriate - comment syntax for the file format. We also recommend that a - file or class name and description of purpose be included on the - same "printed page" as the copyright notice for easier - identification within third-party archives. - - Copyright {yyyy} {name of copyright owner} - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. \ No newline at end of file diff --git a/Godeps/_workspace/src/gopkgs.com/unidecode.v1/README.md b/Godeps/_workspace/src/gopkgs.com/unidecode.v1/README.md deleted file mode 100644 index fa5d70841fb..00000000000 --- a/Godeps/_workspace/src/gopkgs.com/unidecode.v1/README.md +++ /dev/null @@ -1,12 +0,0 @@ -unidecode -========= - -Unicode transliterator in Golang - Replaces non-ASCII characters with their ASCII approximations. - -Please, use the following import path to ensure a stable API: - -```go - import "gopkgs.com/unidecode.v1" -``` - -View other available versions, documentation and examples at http://gopkgs.com/unidecode diff --git a/Godeps/_workspace/src/gopkgs.com/unidecode.v1/gopkgs.go b/Godeps/_workspace/src/gopkgs.com/unidecode.v1/gopkgs.go deleted file mode 100644 index a907de14384..00000000000 --- a/Godeps/_workspace/src/gopkgs.com/unidecode.v1/gopkgs.go +++ /dev/null @@ -1,24 +0,0 @@ -package unidecode - -import ( - "fmt" - "reflect" -) - -// gopkgs.go: v1 - -// NOTE: This file is autogenerated by gopkgs.com. -const ( - goPkgsSrcPath = "github.com/rainycape/unidecode" - goPkgsName = "unidecode" - goPkgsErrFmt = "invalid import path %s - please use gopkgs.com/%s.v1 or see http://gopkgs.com/%s" -) - -type goPkgsCheck struct{} - -func init() { - typ := reflect.TypeOf(goPkgsCheck{}) - if typ.PkgPath() == goPkgsSrcPath { - panic(fmt.Errorf(goPkgsErrFmt, typ.PkgPath(), goPkgsName, goPkgsName)) - } -} diff --git a/README.md b/README.md index bd178eddbdd..0a7db318338 100644 --- a/README.md +++ b/README.md @@ -87,7 +87,7 @@ go get github.com/grafana/grafana ``` cd $GOPATH/src/github.com/grafana/grafana go run build.go setup (only needed once to install godep) -godep restore (will pull down all golang lib dependecies in your current GOPATH) +godep restore (will pull down all golang lib dependencies in your current GOPATH) go build . ``` @@ -125,8 +125,9 @@ You only need to add the options you want to override. Config files are applied 2. dev.ini (if found) 3. custom.ini -## Create a pull requests -Before or after your create a pull requests, sign the [contributor license aggrement](/docs/contributing/cla.html).## Contribute +## Create a pull request +Before or after you create a pull request, sign the [contributor license agreement](http://grafana.org/docs/contributing/cla.html). +## Contribute If you have any idea for an improvement or found a bug do not hesitate to open an issue. And if you have time clone this repo and submit a pull request and help me make Grafana the kickass metrics & devops dashboard we all dream about! diff --git a/bower.json b/bower.json new file mode 100644 index 00000000000..3607de4d8f0 --- /dev/null +++ b/bower.json @@ -0,0 +1,26 @@ +{ + "name": "grafana", + "version": "2.0.2", + "homepage": "https://github.com/grafana/grafana", + "authors": [], + "license": "Apache 2.0", + "ignore": [ + "**/.*", + "node_modules", + "bower_components", + "public/vendor/", + "test", + "tests" + ], + "dependencies": { + "jquery": "~2.1.4", + "angular": "~1.4.0", + "angular-route": "~1.4.0", + "angular-mocks": "~1.4.0", + "angular-sanitize": "~1.4.0", + "angular-native-dragdrop": "~1.1.0", + "angular-bindonce": "~0.3.3", + "requirejs": "~2.1.18", + "requirejs-text": "~2.0.14" + } +} diff --git a/circle.yml b/circle.yml index c2ea5920bb6..32ace393c8b 100644 --- a/circle.yml +++ b/circle.yml @@ -22,3 +22,4 @@ test: - godep go test -v ./pkg/... # js tests - ./node_modules/grunt-cli/bin/grunt test + - npm run coveralls diff --git a/conf/defaults.ini b/conf/defaults.ini index 042f1a5574c..d6d81c8028c 100644 --- a/conf/defaults.ini +++ b/conf/defaults.ini @@ -7,7 +7,7 @@ app_mode = production #################################### Paths #################################### [paths] -# Path to where grafana can store temp files, sessions, and the sqlite3 db (if that is useD) +# Path to where grafana can store temp files, sessions, and the sqlite3 db (if that is used) # data = data # @@ -29,6 +29,10 @@ http_port = 3000 # The public facing domain name used to access grafana from a browser domain = localhost +# Redirect to correct domain if host header does not match domain +# Prevents DNS rebinding attacks +enforce_domain = false + # The full public facing url root_url = %(protocol)s://%(domain)s:%(http_port)s/ @@ -62,14 +66,16 @@ path = grafana.db #################################### Session #################################### [session] -# Either "memory", "file", "redis", "mysql", default is "memory" +# Either "memory", "file", "redis", "mysql", "postgresql", default is "file" provider = file # Provider config options # memory: not have any config yet # file: session dir path, is relative to grafana data_path -# redis: config like redis server addr, poolSize, password, e.g. `127.0.0.1:6379,100,grafana` -# mysql: go-sql-driver/mysql dsn config string, e.g. `user:password@tcp(127.0.0.1)/database_name` +# redis: config like redis server e.g. `addr=127.0.0.1:6379,pool_size=100,db=grafana` +# postgres: user=a password=b host=localhost port=5432 dbname=c sslmode=disable +# mysql: go-sql-driver/mysql dsn config string, e.g. `user:password@tcp(127.0.0.1:3306)/database_name` + provider_config = sessions # Session cookie name @@ -108,6 +114,9 @@ login_remember_days = 7 cookie_username = grafana_user cookie_remember_name = grafana_remember +# disable gravatar profile images +disable_gravatar = false + #################################### Users #################################### [users] # disable user signup / registration @@ -136,18 +145,21 @@ org_role = Viewer #################################### Github Auth ########################## [auth.github] enabled = false +allow_sign_up = false client_id = some_id client_secret = some_secret scopes = user:email auth_url = https://github.com/login/oauth/authorize token_url = https://github.com/login/oauth/access_token api_url = https://api.github.com/user +team_ids = allowed_domains = -allow_sign_up = false +allowed_organizations = #################################### Google Auth ########################## [auth.google] enabled = false +allow_sign_up = false client_id = some_client_id client_secret = some_client_secret scopes = https://www.googleapis.com/auth/userinfo.profile https://www.googleapis.com/auth/userinfo.email @@ -155,7 +167,32 @@ auth_url = https://accounts.google.com/o/oauth2/auth token_url = https://accounts.google.com/o/oauth2/token api_url = https://www.googleapis.com/oauth2/v1/userinfo allowed_domains = -allow_sign_up = false + +#################################### Basic Auth ########################## +[auth.basic] +enabled = true + +#################################### Auth Proxy ########################## +[auth.proxy] +enabled = false +header_name = X-WEBAUTH-USER +header_property = username +auto_sign_up = true + +#################################### SMTP / Emailing ########################## +[smtp] +enabled = false +host = localhost:25 +user = +password = +cert_file = +key_file = +skip_verify = false +from_address = admin@grafana.localhost + +[emails] +welcome_email_on_sign_up = false +templates_pattern = emails/*.html #################################### Logging ########################## [log] @@ -196,3 +233,10 @@ max_days = 7 enabled = false rabbitmq_url = amqp://localhost/ exchange = grafana_events + +#################################### Dashboard JSON files ########################## +[dashboards.json] +enabled = false +path = /var/lib/grafana/dashboards + + diff --git a/conf/sample.ini b/conf/sample.ini index 6aaa69f7314..ef082ccff98 100644 --- a/conf/sample.ini +++ b/conf/sample.ini @@ -7,7 +7,7 @@ #################################### Paths #################################### [paths] -# Path to where grafana can store temp files, sessions, and the sqlite3 db (if that is useD) +# Path to where grafana can store temp files, sessions, and the sqlite3 db (if that is used) # ;data = /var/lib/grafana # @@ -29,6 +29,10 @@ # The public facing domain name used to access grafana from a browser ;domain = localhost +# Redirect to correct domain if host header does not match domain +# Prevents DNS rebinding attacks +;enforce_domain = false + # The full public facing url ;root_url = %(protocol)s://%(domain)s:%(http_port)s/ @@ -62,14 +66,15 @@ #################################### Session #################################### [session] -# Either "memory", "file", "redis", "mysql", default is "memory" +# Either "memory", "file", "redis", "mysql", "postgresql", default is "file" ;provider = file # Provider config options # memory: not have any config yet # file: session dir path, is relative to grafana data_path -# redis: config like redis server addr, poolSize, password, e.g. `127.0.0.1:6379,100,grafana` -# mysql: go-sql-driver/mysql dsn config string, e.g. `user:password@tcp(127.0.0.1)/database_name` +# redis: config like redis server e.g. `addr=127.0.0.1:6379,pool_size=100,db=grafana` +# mysql: go-sql-driver/mysql dsn config string, e.g. `user:password@tcp(127.0.0.1:3306)/database_name` +# postgres: user=a password=b host=localhost port=5432 dbname=c sslmode=disable ;provider_config = sessions # Session cookie name @@ -108,6 +113,9 @@ ;cookie_username = grafana_user ;cookie_remember_name = grafana_remember +# disable gravatar profile images +;disable_gravatar = false + #################################### Users #################################### [users] # disable user signup / registration @@ -136,26 +144,53 @@ #################################### Github Auth ########################## [auth.github] ;enabled = false +;allow_sign_up = false ;client_id = some_id ;client_secret = some_secret -;scopes = user:email +;scopes = user:email,read:org ;auth_url = https://github.com/login/oauth/authorize ;token_url = https://github.com/login/oauth/access_token ;api_url = https://api.github.com/user -# Uncomment bellow to only allow specific email domains -; allowed_domains = mycompany.com othercompany.com +;team_ids = +;allowed_domains = +;allowed_organizations = #################################### Google Auth ########################## [auth.google] ;enabled = false +;allow_sign_up = false ;client_id = some_client_id ;client_secret = some_client_secret ;scopes = https://www.googleapis.com/auth/userinfo.profile https://www.googleapis.com/auth/userinfo.email ;auth_url = https://accounts.google.com/o/oauth2/auth ;token_url = https://accounts.google.com/o/oauth2/token ;api_url = https://www.googleapis.com/oauth2/v1/userinfo -# Uncomment bellow to only allow specific email domains -; allowed_domains = mycompany.com othercompany.com +;allowed_domains = + +#################################### Auth Proxy ########################## +[auth.proxy] +;enabled = false +;header_name = X-WEBAUTH-USER +;header_property = username +;auto_sign_up = true + +#################################### Basic Auth ########################## +[auth.basic] +;enabled = true + +#################################### SMTP / Emailing ########################## +[smtp] +;enabled = false +;host = localhost:25 +;user = +;password = +;cert_file = +;key_file = +;skip_verify = false +;from_address = admin@grafana.localhost + +[emails] +;welcome_email_on_sign_up = false #################################### Logging ########################## [log] @@ -196,3 +231,11 @@ ;enabled = false ;rabbitmq_url = amqp://localhost/ ;exchange = grafana_events + +;#################################### Dashboard JSON files ########################## +[dashboards.json] +;enabled = false +;path = /var/lib/grafana/dashboards + + + diff --git a/docker/blocks/smtp/Dockerfile b/docker/blocks/smtp/Dockerfile new file mode 100644 index 00000000000..c1a3adba7c8 --- /dev/null +++ b/docker/blocks/smtp/Dockerfile @@ -0,0 +1,13 @@ +FROM centos:centos7 +MAINTAINER Przemyslaw Ozgo + +RUN \ + yum update -y && \ + yum install -y net-snmp net-snmp-utils && \ + yum clean all + +COPY bootstrap.sh /tmp/bootstrap.sh + +EXPOSE 161 + +ENTRYPOINT ["/tmp/bootstrap.sh"] diff --git a/docker/blocks/smtp/bootstrap.sh b/docker/blocks/smtp/bootstrap.sh new file mode 100755 index 00000000000..a78f9d6dc16 --- /dev/null +++ b/docker/blocks/smtp/bootstrap.sh @@ -0,0 +1,27 @@ +#!/bin/sh + +set -u + +# User params +USER_PARAMS=$@ + +# Internal params +RUN_CMD="snmpd -f ${USER_PARAMS}" + +####################################### +# Echo/log function +# Arguments: +# String: value to log +####################################### +log() { + if [[ "$@" ]]; then echo "[`date +'%Y-%m-%d %T'`] $@"; + else echo; fi +} + +# Launch +log $RUN_CMD +$RUN_CMD + +# Exit immidiately in case of any errors or when we have interactive terminal +if [[ $? != 0 ]] || test -t 0; then exit $?; fi +log diff --git a/docker/blocks/smtp/fig b/docker/blocks/smtp/fig new file mode 100644 index 00000000000..c2d37e01c21 --- /dev/null +++ b/docker/blocks/smtp/fig @@ -0,0 +1,4 @@ +snmpd: + build: blocks/snmpd + ports: + - "161:161" diff --git a/docker/rpmtest/Dockerfile b/docker/rpmtest/Dockerfile deleted file mode 100644 index 3c18cfa1797..00000000000 --- a/docker/rpmtest/Dockerfile +++ /dev/null @@ -1,6 +0,0 @@ -FROM centos:latest - -RUN yum install -y initscripts - -ADD *.rpm /tmp/ - diff --git a/docs/Makefile b/docs/Makefile index d44bc545e2c..fcb1708f916 100644 --- a/docs/Makefile +++ b/docs/Makefile @@ -44,7 +44,7 @@ docs-test: docs-build $(DOCKER_RUN_DOCS) "$(DOCKER_DOCS_IMAGE)" ./test.sh docs-build: - git fetch https://github.com/grafana/grafana.git docs-2.0 && git diff --name-status FETCH_HEAD...HEAD -- . > changed-files + git fetch https://github.com/grafana/grafana.git docs-1.x && git diff --name-status FETCH_HEAD...HEAD -- . > changed-files echo "$(GIT_BRANCH)" > GIT_BRANCH echo "$(GITCOMMIT)" > GITCOMMIT docker build -t "$(DOCKER_DOCS_IMAGE)" . diff --git a/docs/VERSION b/docs/VERSION index edb49bc725f..7ec1d6db408 100644 --- a/docs/VERSION +++ b/docs/VERSION @@ -1 +1 @@ -2.0.0-beta +2.1.0 diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml index 2ff90577f07..13eaf757c94 100644 --- a/docs/mkdocs.yml +++ b/docs/mkdocs.yml @@ -45,7 +45,7 @@ pages: - ['reference/graph.md', 'Reference', 'Graph Panel'] - ['reference/singlestat.md', 'Reference', 'Singlestat Panel'] -- ['reference/dashlist.md', 'Reference', 'Dashlist Panel'] +- ['reference/dashlist.md', 'Reference', 'Dashboard list Panel'] - ['reference/sharing.md', 'Reference', 'Sharing'] - ['reference/annotations.md', 'Reference', 'Annotations'] - ['reference/timerange.md', 'Reference', 'Time range controls'] @@ -60,8 +60,9 @@ pages: - ['datasources/graphite.md', 'Data Sources', 'Graphite'] - ['datasources/influxdb.md', 'Data Sources', 'InfluxDB'] - ['datasources/opentsdb.md', 'Data Sources', 'OpenTSDB'] +- ['datasources/kairosdb.md', 'Data Sources', 'KairosDB'] -- ['project/building_from_source.md', 'Project', 'Building from souce'] +- ['project/building_from_source.md', 'Project', 'Building from source'] - ['project/cla.md', 'Project', 'Contributor License Agreement'] - ['jsearch.md', '**HIDDEN**'] diff --git a/docs/sources/datasources/graphite.md b/docs/sources/datasources/graphite.md index acf4a78aaa1..d41a987514c 100644 --- a/docs/sources/datasources/graphite.md +++ b/docs/sources/datasources/graphite.md @@ -6,9 +6,9 @@ page_keywords: grafana, graphite, metrics, query, documentation # Graphite -Grafana has an advanced graphite query editor that lets you quickly navigate the metric space, add functions. -Change function paramaters and much more. The editor cannot handle all types of queries yet. -To switch to a regular text box click the pen icon to the right. +Grafana has an advanced Graphite query editor that lets you quickly navigate the metric space, add functions, +change function parameters and much more. The editor can handle all types of graphite queries. It can even handle complex nested +queries through the use of query references. ## Adding the data source to Grafana Open the side menu by clicking the the Grafana icon in the top header. In the side menu under the `Dashboards` link you @@ -52,8 +52,21 @@ Some functions like aliasByNode support an optional second argument. To add this ## Point consolidation -All graphite metrics are consolidated so that graphite doesn't return more data points than there are pixels in the graph. By default -this consolidation is done using `avg` function. You can how graphite consolidates metrics by adding the Graphite consolidateBy function. +All Graphite metrics are consolidated so that Graphite doesn't return more data points than there are pixels in the graph. By default +this consolidation is done using `avg` function. You can how Graphite consolidates metrics by adding the Graphite consolidateBy function. > *Notice* This means that legend summary values (max, min, total) cannot be all correct at the same time. They are calculated > client side by Grafana. And depending on your consolidation function only one or two can be correct at the same time. + +## Templating +You can create a template variable in Grafana and have that variable filled with values from any Graphite metric exploration query. +You can then use this variable in your Graphite queries, either as part of a metric path or as arguments to functions. + +For example a query like `prod.servers.*` will fill the variable with all possible +values that exists in the wildcard position. + +You can also create nested variables that use other variables in their definition. For example +`apps.$app.servers.*` uses the variable `$app` in its query definition. + +![](/img/v2/templated_variable_parameter.png) + diff --git a/docs/sources/datasources/influxdb.md b/docs/sources/datasources/influxdb.md index 04fbd520a7e..05e627967eb 100644 --- a/docs/sources/datasources/influxdb.md +++ b/docs/sources/datasources/influxdb.md @@ -4,10 +4,11 @@ page_description: InfluxDB query guide page_keywords: grafana, influxdb, metrics, query, documentation --- - # InfluxDB -There are currently two separate datasources for InfluxDB in Grafana: InfluxDB 0.8.x and InfluxDB 0.9.x. The API and capabilities of InfluxDB 0.9.x are completely different from InfluxDB 0.8.x. InfluxDB 0.9.x data source support is provided on an experimental basis. +There are currently two separate datasources for InfluxDB in Grafana: InfluxDB 0.8.x and InfluxDB 0.9.x. +The API and capabilities of InfluxDB 0.9.x are completely different from InfluxDB 0.8.x which is why Grafana handles +them as different data sources. ## Adding the data source to Grafana Open the side menu by clicking the the Grafana icon in the top header. In the side menu under the `Dashboards` link you @@ -31,37 +32,73 @@ Password | Database user's password > *Note* When using Proxy access mode the InfluxDB database, user and password will be hidden from the browser/frontend. When > using direct access mode all users will be able to see the database user & password. -## InfluxDB 0.9.x query editor +## InfluxDB 0.9.x -This editor & data source is not compatible with InfluxDB 0.8.x, please use the right data source for you InfluxDB version. -The InfluxDB 0.9.x editor is currently under development and is not yet fully usable. +![](/img/influxdb/InfluxDB_09_editor.png) -## InfluxDB 0.8.x query editor +You find the InfluxDB editor in the metrics tab in Graph or Singlestat panel's edit mode. You enter edit mode by clicking the +panel title, then edit. The editor allows you to select metrics and tags. + +### Editor tag filters +To add a tag filter click the plus icon to the right of the `WHERE` condition. You can remove tag filters by clicking on +the tag key and select `--remove tag filter--`. + +### Regex matching +You can type in regex patterns for metric names or tag filter values, be sure to wrap the regex pattern in forward slashes (`/`). Grafana +will automaticallay adjust the filter tag condition to use the InfluxDB regex match condition operator (`=~`). + +### Editor group by +To group by a tag click the plus icon after the `GROUP BY ($interval)` text. Pick a tag from the dropdown that appears. +You can remove the group by by clicking on the tag and then select `--remove group by--` from the dropdown. + +### Editor RAW Query +You can switch to raw query mode by pressing the pen icon. + +> If you use Raw Query be sure your query at minimum have `WHERE $timeFilter` clause and ends with `order by asc`. +> Also please always have a group by time and an aggregation function, otherwise InfluxDB can easily return hundreds of thousands +> of data points that will hang the browser. + +### Alias patterns + +- $m = replaced with measurement name +- $measurement = replaced with measurement name +- $tag_hostname = replaced with the value of the hostname tag +- You can also use [[tag_hostname]] pattern replacement syntax + +### Templating +You can create a template variable in Grafana and have that variable filled with values from any InfluxDB metric exploration query. +You can then use this variable in your InfluxDB metric queries. + +For example you can have a variable that contains all values for tag `hostname` if you specify a query like this +in the templating edit view. +```sql +SHOW TAG VALUES WITH KEY = "hostname" +``` + +You can also create nested variables. For example if you had another variable, for example `region`. Then you could have +the hosts variable only show hosts from the current selected region with a query like this: + +```sql +SHOW TAG VALUES WITH KEY = "hostname" WHERE region =~ /$region/ +``` + +> Always you `regex values` or `regex wildcard` for All format or multi select format. + +![](/img/influxdb/templating_simple_ex1.png) + +### Annotations +Annotations allows you to overlay rich event information on top of graphs. + +An example query: + +```SQL +SELECT title, description from events WHERE $timeFilter order asc +``` + +### InfluxDB 0.8.x ![](/img/v1/influxdb_editor.png) -When you add an InfluxDB query you can specify series name (can be regex), value column and a function. Group by time can be specified or if left blank will be automatically set depending on how long the current time span is. It will translate to a InfluxDB query that looks like this: - -```sql -select [[func]]([[column]]) from [[series]] where [[timeFilter]] group by time([[interval]]) order asc -``` - -To write the complete query yourself click the cog wheel icon to the right and select ``Raw query mode``. - -## InfluxDB 0.9 Filters & Templates queries - -The InfluxDB 0.9 data source does not currently support filters or templates. - -## InfluxDB 0.8 Filters & Templated queries - -![](/img/animated_gifs/influxdb_templated_query.gif) - - -Use a distinct influxdb query in the filter query input box: - -```sql -select distinct(host) from app.status -``` diff --git a/docs/sources/datasources/kairosdb.md b/docs/sources/datasources/kairosdb.md new file mode 100644 index 00000000000..f0d52b91548 --- /dev/null +++ b/docs/sources/datasources/kairosdb.md @@ -0,0 +1,47 @@ +--- +page_title: KairosDB Guide +page_description: KairosDB guide for Grafana +page_keywords: grafana, kairosdb, documentation +--- + +# KairosDB Guide + +## Adding the data source to Grafana +Open the side menu by clicking the the Grafana icon in the top header. In the side menu under the `Dashboards` link you +should find a link named `Data Sources`. If this link is missing in the side menu it means that your current +user does not have the `Admin` role for the current organization. + + + +Now click the `Add new` link in the top header. + +Name | Description +------------ | ------------- +Name | The data source name, important that this is the same as in Grafana v1.x if you plan to import old dashboards. +Default | Default data source means that it will be pre-selected for new panels. +Url | The http protocol, ip and port of your kairosdb server (default port is usually 8080) +Access | Proxy = access via Grafana backend, Direct = access directory from browser. + +## Query editor +Open a graph in edit mode by click the title. + + + +For details on KairosDB metric queries checkout the offical. + +- [Query Metrics - KairosDB 0.9.4 documentation](http://kairosdb.github.io/kairosdocs/restapi/QueryMetrics.html). + +## Templated queries +KairosDB Datasource Plugin provides following functions in `Variables values query` field in Templating Editor to query `metric names`, `tag names`, and `tag values` to kairosdb server. + +Name | Description +---- | ---- +`metrics(query)` | Returns a list of metric names. If nothing is given, returns a list of all metric names. +`tag_names(query)` | Returns a list of tag names. If nothing is given, returns a list of all tag names. +`tag_values(query)` | Returns a list of tag values. If nothing is given, returns a list of all tag values. + +For details of `metric names`, `tag names`, and `tag values`, please refer to the KairosDB documentations. + +- [List Metric Names - KairosDB 0.9.4 documentation](http://kairosdb.github.io/kairosdocs/restapi/ListMetricNames.html) +- [List Tag Names - KairosDB 0.9.4 documentation](http://kairosdb.github.io/kairosdocs/restapi/ListTagNames.html) +- [List Tag Values - KairosDB 0.9.4 documentation](http://kairosdb.github.io/kairosdocs/restapi/ListTagValues.html) diff --git a/docs/sources/datasources/opentsdb.md b/docs/sources/datasources/opentsdb.md index 09b9f647a5f..e9110418f4c 100644 --- a/docs/sources/datasources/opentsdb.md +++ b/docs/sources/datasources/opentsdb.md @@ -27,7 +27,19 @@ Open a graph in edit mode by click the title. ![](/img/v2/opentsdb_query_editor.png) -For details on opentsdb metric queries checkout the offical [OpenTSDB documentation](http://opentsdb.net/docs/build/html/index.html) +### Auto complete suggestions +You should get auto complete suggestions for tags and tag values. If you do not you need to enable `tsd.core.meta.enable_realtime_ts` in +the OpentSDB server settings. This is required for the OpenTSDB `lookup` api to work. + +## Templating queries + +When using OpenTSDB with a template variable of `query` type you can use following syntax for lookup. + + metrics() // returns metric names + tag_names(cpu) // return tag names (i.e. keys) for a specific cpu metric + tag_values(cpu, hostname) // return tag values for metric cpu and tag key hostname + +For details on opentsdb metric queries checkout the official [OpenTSDB documentation](http://opentsdb.net/docs/build/html/index.html) diff --git a/docs/sources/guides/gettingstarted.md b/docs/sources/guides/gettingstarted.md index b0037668f56..4bb250ef55c 100644 --- a/docs/sources/guides/gettingstarted.md +++ b/docs/sources/guides/gettingstarted.md @@ -19,7 +19,7 @@ The image above shows you the top header for a dashboard. 1. Side menubar toggle: This toggles the side menu, allowing you to focus on the data presented in the dashboard. The side menu provides access to features unrelated to a Dashboard such as Users, Organizations, and Data Sources. 2. Dashboard dropdown: This dropdown shows you which Dashboard you are currently viewing, and allows you to easily switch to a new Dashboard. From here you can also create a new Dashboard, Import existing Dashboards, and manage Dashboard playlists. -3. Star Dashboard: Star (or unstar) the current Dashboar. Starred Dashboards will show up on your own Home Dashboard by default, and are a convenient way to mark Dashboards that you're interested in. +3. Star Dashboard: Star (or unstar) the current Dashboard. Starred Dashboards will show up on your own Home Dashboard by default, and are a convenient way to mark Dashboards that you're interested in. 4. Share Dashboard: Share the current dashboard by creating a link or create a static Snapshot of it. Make sure the Dashboard is saved before sharing. 5. Save dashboard: The current Dashboard will be saved with the current Dashboard name. 6. Settings: Manage Dashboard settings and features such as Templating and Annotations. @@ -28,7 +28,7 @@ The image above shows you the top header for a dashboard. Dashboards are at the core of what Grafana is all about. Dashboards are composed of individual Panels arranged on a number of Rows. By adjusting the display properties of Panels and Rows, you can customize the perfect Dashboard for your exact needs. Each panel can interact with data from any configured Grafana Data Source (currently InfluxDB, Graphite, OpenTSDB, and KairosDB). -This allows you to create a single dashboard that unifies the data across your organization. Panels use the time range specificed +This allows you to create a single dashboard that unifies the data across your organization. Panels use the time range specified in the main Time Picker in the upper right, but they can also have relative time overrides. @@ -40,6 +40,14 @@ in the main Time Picker in the upper right, but they can also have relative time 5. Dashboard panel. You edit panels by clicking the panel title. 6. Graph legend. You can change series colors, y-axis and series visibility directly from the legend. +## Adding & Editing Graphs and Panels + +![](/img/v2/graph_metrics_tab_graphite.png) + +1. You add panels via row menu. The row menu is the green icon to the left of each row. +2. To edit the graph you click on the graph title to open the panel menu, then `Edit`. +3. This should take you to the `Metrics` tab. In this tab you should see the editor for your default data source. + ## Drag-and-Drop panels You can Drag-and-Drop Panels within and between Rows. Click and hold the Panel title, and drag it to its new location. diff --git a/docs/sources/guides/screencasts.md b/docs/sources/guides/screencasts.md index 6c09e5b26af..d8c605a4245 100644 --- a/docs/sources/guides/screencasts.md +++ b/docs/sources/guides/screencasts.md @@ -15,10 +15,9 @@ no_toc: true

Episode 2 - Templated Graphite Queries

- +
-
@@ -34,7 +33,6 @@ no_toc: true
-
@@ -50,7 +48,6 @@ no_toc: true
-
diff --git a/docs/sources/index.md b/docs/sources/index.md index 24ba087575e..491ff31377d 100644 --- a/docs/sources/index.md +++ b/docs/sources/index.md @@ -4,13 +4,13 @@ page_keywords: grafana, introduction, documentation, about # About Grafana -Grafana is a leading open source applications for visualizing large-scale measurement data. +Grafana is a leading open source application for visualizing large-scale measurement data. It provides a powerful and elegant way to create, share, and explore data and dashboards from your disparate metric databases, either with your team or the world. Grafana is most commonly used for Internet infrastructure and application analytics, but many use it in other domains including industrial sensors, home automation, weather, and process control. -Grafana features pluggable panels and data sources allowing easy extensibility. There is currently rich support for [Graphite](http://graphite.readthedocs.org/en/latest/), [InfluxDB](http://influxdb.org) and [OpenTSDB](http://opentsdb.net). There is also experimental support for KairosDB, and SQL is on the roadmap. Grafana has a variety of panels, including a fully featured graph panel with rich visualization options. +Grafana features pluggable panels and data sources allowing easy extensibility. There is currently rich support for [Graphite](http://graphite.readthedocs.org/en/latest/), [InfluxDB](http://influxdb.org) and [OpenTSDB](http://opentsdb.net). There is also experimental support for [KairosDB](https://github.com/kairosdb/kairosdb), and SQL is on the roadmap. Grafana has a variety of panels, including a fully featured graph panel with rich visualization options. Version 2.0 was released in April 2015: Grafana now ships with its own backend server that brings [many changes and features](../guides/whats-new-in-v2/). diff --git a/docs/sources/installation/configuration.md b/docs/sources/installation/configuration.md index d727f9ced41..6310ccb7973 100644 --- a/docs/sources/installation/configuration.md +++ b/docs/sources/installation/configuration.md @@ -6,173 +6,243 @@ page_keywords: grafana, configuration, documentation # Configuration -The Grafana backend has a number of configuration options that can be specified in a `.ini` config file -or specified using `ENV` variables. +The Grafana back-end has a number of configuration options that can be +specified in a `.ini` configuration file or specified using environment variables. ## Config file locations - Default configuration from `$WORKING_DIR/conf/defaults.ini` - Custom configuration from `$WORKING_DIR/conf/custom.ini` -- The custom config file path can be overriden using the `--config` parameter +- The custom configuration file path can be overridden using the `--config` parameter -> **Note.** If you have installed grafana using the `deb` or `rpm` packages, then your configuration file is located -> at `/etc/grafana/grafana.ini`. This path is specified in the grafana init.d script using `--config` file -> parameter. +> **Note.** If you have installed Grafana using the `deb` or `rpm` +> packages, then your configuration file is located at +> `/etc/grafana/grafana.ini`. This path is specified in the Grafana +> init.d script using `-config` file parameter. -## Using ENV variables -All options in the config file (listed below) can be overriden using ENV variables using the syntax: +## Using environment variables + +All options in the configuration file (listed below) can be overridden +using environment variables using the syntax: GF__ -Where the section name is the text within the brackets. Everything should be upper case. - -Example, given this config setting: +Where the section name is the text within the brackets. Everything +should be upper case, `.` should be replaced by `_`. For example, given these configuration settings: [security] admin_user = admin + [auth.google] + client_secret = 0ldS3cretKey + + Then you can override that using: export GF_SECURITY_ADMIN_USER=true + export GF_AUTH_GOOGLE_CLIENT_SECRET=newS3cretKey
+ ## [paths] ### data -Path to where grafana can store the sqlite3 database (if used), file based sessions (if used), and other data. -This path is usually specified via command line in the init.d script or the systemd service file. + +Path to where Grafana stores the sqlite3 database (if used), file based +sessions (if used), and other data. This path is usually specified via +command line in the init.d script or the systemd service file. ### logs -Path to where grafana can store logs. This path is usually specified via command line in the init.d script or the systemd service file. -It can be overriden in the config file or in the default environment variable file. + +Path to where Grafana will store logs. This path is usually specified via +command line in the init.d script or the systemd service file. It can +be overridden in the configuration file or in the default environment variable +file. ## [server] ### http_addr -The ip address to bind to, if empty will bind to all interfaces + +The IP address to bind to, if empty will bind to all interfaces ### http_port -The port to bind to, defaults to `3000`. To use port 80 you need to either give the grafana binary permission for example: -``` -$ sudo setcap 'cap_net_bind_service=+ep' /opt/grafana/current/grafana -``` +The port to bind to, defaults to `3000`. To use port 80 you need to +either give the Grafana binary permission for example: -Or redirect port 80 to the grafana port using: -``` -$ sudo iptables -t nat -A PREROUTING -p tcp --dport 80 -j REDIRECT --to-port 3000 -``` + $ sudo setcap 'cap_net_bind_service=+ep' /opt/grafana/current/grafana -Another way is put nginx or apache infront of Grafana and have them proxy requests to Grafana. +Or redirect port 80 to the Grafana port using: + + $ sudo iptables -t nat -A PREROUTING -p tcp --dport 80 -j REDIRECT --to-port 3000 + +Another way is put a webserver like Nginx or Apache in front of Grafana and have them proxy requests to Grafana. ### protocol + `http` or `https` ### domain -This setting is only used in as a part of the root_url setting (see below). Important if you -use github or google oauth. + +This setting is only used in as a part of the `root_url` setting (see below). Important if you +use GitHub or Google OAuth. + +### enforce_domain + +Redirect to correct domain if host header does not match domain. +Prevents DNS rebinding attacks. Default is false. ### root_url -This is the full url used to access grafana from a web browser. This is important if you use -google or github oauth authentication (for the callback url to be correct). -> **Note** This setting is also important if you have a reverse proxy infront of Grafana -> that exposes grafana through a subpath. In that case add the subpath to the end of this url setting. +This is the full URL used to access Grafana from a web browser. This is +important if you use Google or GitHub OAuth authentication (for the +callback URL to be correct). + +> **Note** This setting is also important if you have a reverse proxy +> in front of Grafana that exposes it through a subpath. In that +> case add the subpath to the end of this URL setting. ### static_root_path -The path to the directory where the frontend files (html & js & css). Default to `public` which is -why the Grafana binary needs to be executed with working directory set to the installation path. + +The path to the directory where the front end files (HTML, JS, and CSS +files). Default to `public` which is why the Grafana binary needs to be +executed with working directory set to the installation path. ### cert_file -Path to cert file (if protocol is https) + +Path to the certificate file (if `protocol` is set to `https`). ### cert_key -Path to cert key file (if protocol is https) + +Path to the certificate key file (if `protocol` is set to `https`).

+ ## [database] -Grafana needs a database to store users and dashboards (and other things). By default it is configured to -use `sqlite3` which is an embedded database (included in the main Grafana binary). +Grafana needs a database to store users and dashboards (and other +things). By default it is configured to use `sqlite3` which is an +embedded database (included in the main Grafana binary). ### type + Either `mysql`, `postgres` or `sqlite3`, it's your choice. ### path -Only applicable for `sqlite3` database. The file path where the database will be stored. + +Only applicable for `sqlite3` database. The file path where the database +will be stored. ### host -Only applicable to mysql or postgres. Include ip/hostname & port. -Example for mysql same host as Grafana: `host = 127.0.0.1:3306` + +Only applicable to MySQL or Postgres. Includes IP or hostname and port. +For example, for MySQL running on the same host as Grafana: `host = +127.0.0.1:3306` ### name -The name of the grafana database. Leave it set to `grafana` or some other name. + +The name of the Grafana database. Leave it set to `grafana` or some +other name. ### user + The database user (not applicable for `sqlite3`). ### password + The database user's password (not applicable for `sqlite3`). ### ssl_mode -For `postgres` only, either "disable", "require" or "verify-full". + +For `postgres` only, either `disable`, `require` or `verify-full`.
+ ## [security] ### admin_user -The name of the default grafana admin user (who has full permissions). Defaults to `admin`. + +The name of the default Grafana admin user (who has full permissions). +Defaults to `admin`. ### admin_password -The password of the default grafana admin. Defaults to `admin`. + +The password of the default Grafana admin. Defaults to `admin`. ### login_remember_days + The number of days the keep me logged in / remember me cookie lasts. ### secret_key + Used for signing keep me logged in / remember me cookies. +### disable_gravatar + +Set to `true` to disable the use of Gravatar for user profile images. +Default is `false`. +
-## [user] + +## [users] ### allow_sign_up -Set to `false` to prohibit users from being able to sign up / create user accounts. Defaults to `true`. -The admin can still create users from the [Grafana Admin Pages](../reference/admin.md) + +Set to `false` to prohibit users from being able to sign up / create +user accounts. Defaults to `true`. The admin user can still create +users from the [Grafana Admin Pages](../reference/admin.md) ### allow_org_create -Set to `false` to prohibit users from creating new organizations. Defaults to `true`. + +Set to `false` to prohibit users from creating new organizations. +Defaults to `true`. ### auto_assign_org -Set to `true` to automatically add new users to the main organization (id 1). When set to `false`, -new users will automatically cause a new organization to be created for that new user. + +Set to `true` to automatically add new users to the main organization +(id 1). When set to `false`, new users will automatically cause a new +organization to be created for that new user. ### auto_assign_org_role -The role new users will be assigned for the main organization (if the above setting is set to true). -Defaults to `Viewer`, other valid options are `Admin` and `Editor`. + +The role new users will be assigned for the main organization (if the +above setting is set to true). Defaults to `Viewer`, other valid +options are `Admin` and `Editor`.
+ ## [auth.anonymous] ### enabled -Set to `true` to enable anonymous access. Defaults to `false` -### org_name -Set the organization name that should be used for anonymous users. If you change your organization name -in the Grafana UI this setting needs to be updated to match the new name. -### org_role -Specify role for anonymous users. Defaults to `Viewer`, other valid options are `Editor` and `Admin`. +Set to `true` to enable anonymous access. Defaults to `false` + +### org_name + +Set the organization name that should be used for anonymous users. If +you change your organization name in the Grafana UI this setting needs +to be updated to match the new name. + +### org_role + +Specify role for anonymous users. Defaults to `Viewer`, other valid +options are `Editor` and `Admin`. ## [auth.github] -You need to create a github application (you find this under the github profile page). When -you create the application you will need to specify a callback URL. Specify this as callback: + +You need to create a GitHub application (you find this under the GitHub +profile page). When you create the application you will need to specify +a callback URL. Specify this as callback: http://:/login/github -This callback url must match the full http address that you use in your browser to access grafana, but -with the prefix path of `/login/github`. When the github application is created you will get a -Client ID and a Client Secret. Specify these in the grafana config file. Example: +This callback URL must match the full HTTP address that you use in your +browser to access Grafana, but with the prefix path of `/login/github`. +When the GitHub application is created you will get a Client ID and a +Client Secret. Specify these in the Grafana configuration file. For +example: [auth.github] enabled = true @@ -182,22 +252,47 @@ Client ID and a Client Secret. Specify these in the grafana config file. Example auth_url = https://github.com/login/oauth/authorize token_url = https://github.com/login/oauth/access_token allow_sign_up = false + team_ids = -Restart the grafana backend. You should now see a github login button on the login page. You can -now login or signup with your github accounts. +Restart the Grafana back-end. You should now see a GitHub login button +on the login page. You can now login or sign up with your GitHub +accounts. -You may allow users to sign-up via github auth by setting allow_sign_up to true. When this option is -set to true, any user successfully authenticating via github auth will be automatically signed up. +You may allow users to sign-up via GitHub authentication by setting the +`allow_sign_up` option to `true`. When this option is set to `true`, any +user successfully authenticating via GitHub authentication will be +automatically signed up. + +### team_ids + +Require an active team membership for at least one of the given teams on +GitHub. If the authenticated user isn't a member of at least one the +teams they will not be able to register or authenticate with your +Grafana instance. For example: + + [auth.github] + enabled = true + client_id = YOUR_GITHUB_APP_CLIENT_ID + client_secret = YOUR_GITHUB_APP_CLIENT_SECRET + scopes = user:email + team_ids = 150,300 + auth_url = https://github.com/login/oauth/authorize + token_url = https://github.com/login/oauth/access_token + allow_sign_up = false ## [auth.google] -You need to create a google project. You can do this in the [Google Developer Console](https://console.developers.google.com/project). -When you create the project you will need to specify a callback URL. Specify this as callback: + +You need to create a Google project. You can do this in the [Google +Developer Console](https://console.developers.google.com/project). When +you create the project you will need to specify a callback URL. Specify +this as callback: http://:/login/google -This callback url must match the full http address that you use in your browser to access grafana, but -with the prefix path of `/login/google`. When the google project is created you will get a -Client ID and a Client Secret. Specify these in the grafana config file. Example: +This callback URL must match the full HTTP address that you use in your +browser to access Grafana, but with the prefix path of `/login/google`. +When the Google project is created you will get a Client ID and a Client +Secret. Specify these in the Grafana configuration file. For example: [auth.google] enabled = true @@ -206,28 +301,38 @@ Client ID and a Client Secret. Specify these in the grafana config file. Example scopes = https://www.googleapis.com/auth/userinfo.profile https://www.googleapis.com/auth/userinfo.email auth_url = https://accounts.google.com/o/oauth2/auth token_url = https://accounts.google.com/o/oauth2/token - allowed_domains = mycompany.com + allowed_domains = mycompany.com mycompany.org allow_sign_up = false -Restart the grafana backend. You should now see a google login button on the login page. You can -now login or signup with your google accounts. `allowed_domains` option is optional. +Restart the Grafana back-end. You should now see a Google login button +on the login page. You can now login or sign up with your Google +accounts. The `allowed_domains` option is optional, and domains were separated by space. -You may allow users to sign-up via google auth by setting allow_sign_up to true. When this option is -set to true, any user successfully authenticating via google auth will be automatically signed up. +You may allow users to sign-up via Google authentication by setting the +`allow_sign_up` option to `true`. When this option is set to `true`, any +user successfully authenticating via Google authentication will be +automatically signed up.
+ ## [session] ### provider -Valid values are "memory", "file", "mysql", 'postgres'. Default is "memory". + +Valid values are `memory`, `file`, `mysql`, `postgres`. Default is `file`. ### provider_config -This option should be configured differently depending on what type of session provider you have configured. + +This option should be configured differently depending on what type of +session provider you have configured. - **file:** session file path, e.g. `data/sessions` -- **mysql:** go-sql-driver/mysql dsn config string, e.g. `user:password@tcp(127.0.0.1)/database_name` +- **mysql:** go-sql-driver/mysql dsn config string, e.g. `user:password@tcp(127.0.0.1:3306)/database_name` +- **postgres:** ex: user=a password=b host=localhost port=5432 dbname=c sslmode=disable + +If you use MySQL or Postgres as the session store you need to create the +session table manually. -if you use mysql or postgres as session store you need to create the session table manually. Mysql Example: CREATE TABLE `session` ( @@ -238,24 +343,38 @@ Mysql Example: ) ENGINE=MyISAM DEFAULT CHARSET=utf8; ### cookie_name -The name of the grafana session cookie + +The name of the Grafana session cookie. ### cookie_secure + Set to true if you host Grafana behind HTTPs only. Defaults to `false`. ### session_life_time + How long sessions lasts in seconds. Defaults to `86400` (24 hours). ## [analytics] ### reporting_enabled -When enabled Grafana will send anonymous usage statistics to stats.grafana.org. -No ip addresses are being tracked, only simple counters to track running instances, + +When enabled Grafana will send anonymous usage statistics to `stats.grafana.org`. +No IP addresses are being tracked, only simple counters to track running instances, versions, dashboard & error counts. It is very helpful to us, please leave this enabled. Counters are sent every 24 hours. Default value is `true`. ### google_analytics_ua_id -If you want to track Grafana usage via Google analytics specify *your* Univeral Analytics ID -here. By defualt this feature is disabled. +If you want to track Grafana usage via Google analytics specify *your* Universal Analytics ID +here. By default this feature is disabled. +## [dashboards.json] + +If you have a system that automatically builds dashboards as json files you can enable this feature to have the +Grafana backend index those json dashboards which will make them appear in regular dashboard search. + +### enabled +`true` or `false`. Is disabled by default. + +### path +The full path to a directory containing your json dashboards. diff --git a/docs/sources/installation/debian.md b/docs/sources/installation/debian.md index 4bba51ec530..07a7d451626 100644 --- a/docs/sources/installation/debian.md +++ b/docs/sources/installation/debian.md @@ -16,80 +16,93 @@ Description | Download $ wget https://grafanarel.s3.amazonaws.com/builds/grafana_2.0.2_amd64.deb $ sudo apt-get install -y adduser libfontconfig - $ sudo dpkg -i grafana_2.0.1_amd64.deb + $ sudo dpkg -i grafana_2.0.2_amd64.deb ## APT Repository -Add the following line to your `/etc/apt/sources.list` +Add the following line to your `/etc/apt/sources.list` file. deb https://packagecloud.io/grafana/stable/debian/ wheezy main -Use the above line even if you are on Ubuntu or another debian version. There is also testing -repository if you want beta or release candidates. +Use the above line even if you are on Ubuntu or another Debian version. +There is also a testing repository if you want beta or release +candidates. deb https://packagecloud.io/grafana/testing/debian/ wheezy main -Then add the [Package Cloud](https://packagecloud.io/grafana) key (signs repo metadata). +Then add the [Package Cloud](https://packagecloud.io/grafana) key. This +allows you to install signed packages. $ curl https://packagecloud.io/gpg.key | sudo apt-key add - -Update apt and install Grafana +Update your Apt repositories and install Grafana $ sudo apt-get update $ sudo apt-get install grafana -On some older versions of Ubuntu and Debian you may need to install `apt-transport-https`, -needed to fetch packages over HTTPS. +On some older versions of Ubuntu and Debian you may need to install the +`apt-transport-https` package which is needed to fetch packages over +HTTPS. $ sudo apt-get install -y apt-transport-https ## Package details - Installs binary to `/usr/sbin/grafana-server` -- Init.d script to `/etc/init.d/grafana-server` -- Default file (environment vars) to `/etc/default/grafana-server` -- Configuration file to `/etc/grafana/grafana.ini` -- Systemd service (if systemd is available) name `grafana-server.service` -- The default configuration specifies log file at `/var/log/grafana/grafana.log` -- The default configuration specifies sqlite3 db at `/var/lib/grafana/grafana.db` +- Installs Init.d script to `/etc/init.d/grafana-server` +- Creates default file (environment vars) to `/etc/default/grafana-server` +- Installs configuration file to `/etc/grafana/grafana.ini` +- Installs systemd service (if systemd is available) name `grafana-server.service` +- The default configuration sets the log file at `/var/log/grafana/grafana.log` +- The default configuration specifies an sqlite3 db at `/var/lib/grafana/grafana.db` ## Start the server (init.d service) -- Start grafana by `sudo service grafana-server start` -- This will start the grafana-server process as the `grafana` user (created during package install) -- Default http port is `3000`, and default user is admin/admin +You can start Grafana by running: -To configure Grafana server to start at boot time: + $ sudo service grafana-server start + +This will start the `grafana-server` process as the `grafana` user, +which was created during the package installation. The default HTTP port +is `3000` and default user and group is `admin`. + +To configure the Grafana server to start at boot time: $ sudo update-rc.d grafana-server defaults 95 10 ## Start the server (via systemd) + +To start the service using systemd. + $ systemctl daemon-reload $ systemctl start grafana-server $ systemctl status grafana-server -Enable the systemd service (so grafana starts at boot) +Enable the systemd service so that Grafana starts at boot. sudo systemctl enable grafana-server.service ## Environment file -The systemd service file and init.d script both use the file located at `/etc/default/grafana-server` for -environment variables used when starting the backend. Here you can override log directory, data directory and other -variables. +The systemd service file and init.d script both use the file located at +`/etc/default/grafana-server` for environment variables used when +starting the back-end. Here you can override log directory, data +directory and other variables. ### Logging -By default grafana will log to `/var/log/grafana` +By default Grafana will log to `/var/log/grafana` ### Database -The default configuration specifies a sqlite3 database located at `/var/lib/grafana/grafana.db`. Please backup -this database before upgrades. You can also use mysql or postgres as the Grafana database. +The default configuration specifies a sqlite3 database located at +`/var/lib/grafana/grafana.db`. Please backup this database before +upgrades. You can also use MySQL or Postgres as the Grafana database. ## Configuration -The configuration file is located at `/etc/grafana/grafana.ini`. Go the [Configuration](configuration) page for details -on all those options. +The configuration file is located at `/etc/grafana/grafana.ini`. Go the +[Configuration](/installation/configuration) page for details on all +those options. ### Adding data sources @@ -99,12 +112,18 @@ on all those options. ## Installing from binary tar file -Start by [downloading](http://grafana.org/download/builds) the latest `.tar.gz` file and extract it. -This will extract into a folder named after the version you downloaded. This folder contains all files required to run grafana. -There are no init scripts or install scripts in this package. +Start by [downloading](http://grafana.org/download/builds) the latest +`.tar.gz` file and extract it. This will extract into a folder named +after the version you downloaded. This folder contains all files +required to run Grafana. There are no init scripts or install scripts +in this package. -To configure grafana add a config file named `custom.ini` to the `conf` folder and override any of the settings defined in -`conf/defaults.ini`. Start grafana by excecuting `./grafana web`. The grafana binary needs the working directory -to be the root install dir (where the binary is and the public folder is located). +To configure Grafana add a configuration file named `custom.ini` to the +`conf` folder and override any of the settings defined in +`conf/defaults.ini`. + +Start Grafana by executing `./grafana web`. The `grafana` binary needs +the working directory to be the root install directory (where the binary +and the `public` folder is located). diff --git a/docs/sources/installation/docker.md b/docs/sources/installation/docker.md index 5ec9e4f697e..c11de0faa7b 100644 --- a/docs/sources/installation/docker.md +++ b/docs/sources/installation/docker.md @@ -8,27 +8,31 @@ page_keywords: grafana, installation, docker, container, guide ## Install from offical docker image -Grafana has an offical docker container. +Grafana has an official Docker container. $ docker run -i -p 3000:3000 grafana/grafana -All grafana configuration settings can be defined using ENVIRONMENT variables, this is especially useful when using the -above container. +All Grafana configuration settings can be defined using environment +variables, this is especially useful when using the above container. ## Docker volumes & ENV config -The docker container exposes two volumes, the sqlite3 database in the folder `/var/lib/grafana` and -configuration files is in `/etc/grafana/` folder. You can map these volumes to host folders when you start the container: +The Docker container exposes two volumes, the sqlite3 database in the +folder `/var/lib/grafana` and configuration files is in `/etc/grafana/` +folder. You can map these volumes to host folders when you start the +container: $ docker run -d -p 3000:3000 \ -v /var/lib/grafana:/var/lib/grafana \ - -e "GF_SECURITY_ADMIN_PASSWORD=secret \ + -e "GF_SECURITY_ADMIN_PASSWORD=secret" \ grafana/grafana:develop -In the above example I map the data folder and set a config option via an `ENV` variable. +In the above example I map the data folder and sets a configuration option via +an `ENV` instruction. ## Configuration -The backend web server has a number of configuration options. Go the [Configuration](configuration) page for details -on all those options. +The back-end web server has a number of configuration options. Go the +[Configuration](../installation/configuration.md) page for details on all +those options. diff --git a/docs/sources/installation/index.md b/docs/sources/installation/index.md index 4956fa81809..8116a44d2bd 100644 --- a/docs/sources/installation/index.md +++ b/docs/sources/installation/index.md @@ -6,9 +6,12 @@ page_keywords: grafana, installation, documentation # Installation -Grafana is easily installed via a Debian/Ubuntu package (.deb), via Redhat/Centos package (.rpm) or manually via -a tar that contains all required files and binaries. If you can't find a package or binary for your platform you might be able -to build one your self, read [build from source](../project/building_from_source) instructions for more information. +Grafana is easily installed via a Debian/Ubuntu package (.deb), via +Redhat/Centos package (.rpm) or manually via a tarball that contains all +required files and binaries. If you can't find a package or binary for +your platform you might be able to build one your self, read the [build +from source](../project/building_from_source) instructions for more +information. - [Installing on Debian / Ubuntu](debian.md) - [Installing on RPM-based Linux (CentOS, Fedora, OpenSuse, RedHat)](rpm.md) @@ -20,8 +23,9 @@ to build one your self, read [build from source](../project/building_from_source ## Configuration -The backend web server has a number of configuration options. Go the [Configuration](configuration) page for details -on all those options. +The back-end web server has a number of configuration options. Go the +[Configuration](/installation/configuration) page for details on all +those options. ## Adding data sources diff --git a/docs/sources/installation/mac.md b/docs/sources/installation/mac.md index a5703b4ed7e..48f8ca075a3 100644 --- a/docs/sources/installation/mac.md +++ b/docs/sources/installation/mac.md @@ -6,7 +6,8 @@ page_keywords: grafana, installation, mac, osx, guide # Installing on Mac -There are currently no binary build for Mac. But read the [build from source](../project/building_from_source) -page for instructions on how to build it yourself. +There is currently no binary build for Mac. But read the [build from +source](/project/building_from_source) page for instructions on how to +build it yourself. diff --git a/docs/sources/installation/migrating_to2.md b/docs/sources/installation/migrating_to2.md index b3d4dab62ce..497b53e77e2 100644 --- a/docs/sources/installation/migrating_to2.md +++ b/docs/sources/installation/migrating_to2.md @@ -6,53 +6,81 @@ page_keywords: grafana, installation, migration, documentation # Migrating from v1.x to v2.x -Grafana 2.0 represents a major update to Grafana. It brings new capabilities, many of which are enabled by its new backend server and integrated database. +Grafana 2.0 represents a major update to Grafana. It brings new +capabilities, many of which are enabled by its new back-end server and +integrated database. -The new backend lays a solid foundation that we hope to build on over the coming months. For the 2.0 release, it enables authentication as well as server-side sharing and rendering. +The new back-end lays a solid foundation that we hope to build on over +the coming months. For the 2.0 release, it enables authentication as +well as server-side sharing and rendering. -We've attempted to provide a smooth migration path for V1.9 users to migrate to Grafana 2.0. +We've attempted to provide a smooth migration path for v1.9 users to +migrate to Grafana 2.0. ## Adding Data sources -The config.js file has been deprecated. Data sources are now managed via the UI or [HTTP API](../reference/http_api.md). Manage your organizations data sources by clicking on the `Data Sources` menu on the side menu (which can be toggled via the Grafana icon in the upper left of your browser). +The `config.js` file has been deprecated. Data sources are now managed via +the UI or [HTTP API](../reference/http_api.md). Manage your +organizations data sources by clicking on the `Data Sources` menu on the +side menu (which can be toggled via the Grafana icon in the upper left +of your browser). -From here, you can add any Graphite, InfluxDB, elasticsearch, and OpenTSDB datasources that you were using with Grafana 1.x. Grafana 2.0 can be configured to communicate with your datasource using a backend mode which can eliminate many CORS-related issues, as well as provide more secure authentication to your datasources. +From here, you can add any Graphite, InfluxDB, elasticsearch, and +OpenTSDB data sources that you were using with Grafana 1.x. Grafana 2.0 +can be configured to communicate with your data source using a back-end +mode which can eliminate many CORS-related issues, as well as provide +more secure authentication to your data sources. -> *Note* When you add your data sources please name them exacly as you named them in config.js in Grafana 1.x. That name is referenced by panels -> , annotation and template queries. That way when you import your old dashboard they will work without any changes. +> *Note* When you add your data sources please name them exactly as you +> named them in `config.js` in Grafana 1.x. That name is referenced by +> panels, annotation and template queries. That way when you import +> your old dashboard they will work without any changes. ## Importing your existing dashboards -Grafana 2.0 now has integrated dashboard storage engine that can be configured to use an internal sqlite database, MySQL, or Postgres. This eliminates the need to use Elasticsearch for dashboard storage for Graphite users. Grafana 2.0 does not support storing dashboards in InfluxDB. +Grafana 2.0 now has integrated dashboard storage engine that can be +configured to use an internal sqlite3 database, MySQL, or Postgres. This +eliminates the need to use Elasticsearch for dashboard storage for +Graphite users. Grafana 2.0 does not support storing dashboards in +InfluxDB. You can seamlessly import your existing dashboards. -### dashboards from Elasticsearch +### Importing dashboards from Elasticsearch -Start by going to the `Data Sources` view (via the side menu), and make sure your elasticsearch datasource is added. Specify the elasticsearch index name where your existing Grafana v1.x dashboards are stored (default is `grafana-dash`). +Start by going to the `Data Sources` view (via the side menu), and make +sure your Elasticsearch data source is added. Specify the Elasticsearch +index name where your existing Grafana v1.x dashboards are stored +(the default is `grafana-dash`). ![](/img/v2/datasource_edit_elastic.jpg) -### dashboards from InfluxDB +### Importing dashboards from InfluxDB -Start by going to the `Data Sources` view (via the side menu), and make sure your InfluxDB datasource is added. Specify the database name where your Grafana v1.x dashboards are stored, default is `grafana`. +Start by going to the `Data Sources` view (via the side menu), and make +sure your InfluxDB data source is added. Specify the database name where +your Grafana v1.x dashboards are stored, the default is `grafana`. ### Go to Import dashboards view -Go to the `Dashboards` view and click on the dashboards search dropdown. Click the `Import` button at the bottom of the search dropdown. +Go to the `Dashboards` view and click on the dashboards search drop +down. Click the `Import` button at the bottom of the search drop down. ![](/img/v2/dashboard_import.jpg) ### Import view -In the Import view you find the section `Migrate dashboards`. Pick the datasource you added (from elasticsearch or InfluxDB), -and click the `Import` button. +In the Import view you find the section `Migrate dashboards`. Pick the +data source you added (from Elasticsearch or InfluxDB), and click the +`Import` button. ![](/img/v2/migrate_dashboards.jpg) -Your dashboards should be automatically imported into the Grafana 2.0 backend. +Your dashboards should be automatically imported into the Grafana 2.0 +back-end. -Dashboards will no longer be stored in your previous elasticsearch or InfluxDB databases. +Dashboards will no longer be stored in your previous Elasticsearch or +InfluxDB databases. ### Invite your team diff --git a/docs/sources/installation/performance.md b/docs/sources/installation/performance.md index ce41e7a0548..6dcc38b5f78 100644 --- a/docs/sources/installation/performance.md +++ b/docs/sources/installation/performance.md @@ -8,9 +8,15 @@ page_keywords: grafana, performance, documentation ## Graphite -Graphite 0.9.13 adds a much needed feature to the json rendering API that is very important for Grafana. If you are experiance slow -load & rendering times for large time ranges then it is most likely caused by running Graphite 0.9.12 or lower. The latest version -of Graphite adds a maxDataPoints parameter to the json render API, without this feature Graphite can return hundreds of thousands of data points per graph, which -can hang your browser. Be sure to upgrade to [0.9.13](http://graphite.readthedocs.org/en/latest/releases/0_9_13.html). +Graphite 0.9.13 adds a much needed feature to the JSON rendering API +that is very important for Grafana. If you are experiencing slow load & +rendering times for large time ranges then it is most likely caused by +running Graphite 0.9.12 or lower. + +The latest version of Graphite adds a `maxDataPoints` parameter to the +JSON render API, without this feature Graphite can return hundreds of +thousands of data points per graph, which can hang your browser. Be sure +to upgrade to +[0.9.13](http://graphite.readthedocs.org/en/latest/releases/0_9_13.html). diff --git a/docs/sources/installation/provisioning.md b/docs/sources/installation/provisioning.md index b7324721e58..ea57b0d00dd 100644 --- a/docs/sources/installation/provisioning.md +++ b/docs/sources/installation/provisioning.md @@ -6,8 +6,9 @@ page_keywords: grafana, provisioning, documentation # Provisioning -Here are links for how to install Grafana (and some include graphite or influxdb as well) via a provisioning -system. These are not maintained by any core Grafana team member and might be out of date. +Here are links for how to install Grafana (and some include Graphite or +InfluxDB as well) via a provisioning system. These are not maintained by +any core Grafana team member and might be out of date. ## Puppet @@ -17,6 +18,7 @@ system. These are not maintained by any core Grafana team member and might be ou * [github.com/bobrik/ansible-grafana](https://github.com/bobrik/ansible-grafana) * [github.com/bitmazk/ansible-digitalocean-influxdb-grafana](https://github.com/bitmazk/ansible-digitalocean-influxdb-grafana) +* [github.com/picotrading/ansible-grafana](https://github.com/picotrading/ansible-grafana) ## Docker diff --git a/docs/sources/installation/rpm.md b/docs/sources/installation/rpm.md index 9b7dc0ed9ed..ab0f5e3be24 100644 --- a/docs/sources/installation/rpm.md +++ b/docs/sources/installation/rpm.md @@ -12,17 +12,18 @@ Description | Download ------------ | ------------- .RPM for Fedora / RHEL / CentOS Linux | [grafana-2.0.2-1.x86_64.rpm](https://grafanarel.s3.amazonaws.com/builds/grafana-2.0.2-1.x86_64.rpm) -## Install -You can install using yum +## Install from package file + +You can install Grafana using Yum directly. $ sudo yum install https://grafanarel.s3.amazonaws.com/builds/grafana-2.0.2-1.x86_64.rpm -Or manually using `rpm` +Or install manually using `rpm`. $ sudo yum install initscripts fontconfig $ sudo rpm -Uvh grafana-2.0.1-1.x86_64.rpm -## YUM Repository +## Install via YUM Repository Add the following to a new file at `/etc/yum.repos.d/grafana.repo` @@ -36,33 +37,43 @@ Add the following to a new file at `/etc/yum.repos.d/grafana.repo` sslverify=1 sslcacert=/etc/pki/tls/certs/ca-bundle.crt -There is also testing repository if you want beta or release candidates. +There is also a testing repository if you want beta or release +candidates. baseurl=https://packagecloud.io/grafana/testing/el/6/$basearch -Install Grafana +Then install Grafana via the `yum` command. $ sudo yum install grafana ### RPM GPG Key -The rpms are signed, you can verify the signature with this [public GPG key](https://grafanarel.s3.amazonaws.com/RPM-GPG-KEY-grafana). + +The RPMs are signed, you can verify the signature with this [public GPG +key](https://grafanarel.s3.amazonaws.com/RPM-GPG-KEY-grafana). ## Package details - Installs binary to `/usr/sbin/grafana-server` -- Init.d script to `/etc/init.d/grafana-server` -- Default file (environment vars) to `/etc/sysconfig/grafana-server` -- Configuration file to `/etc/grafana/grafana.ini` -- Systemd service (if systemd is available) name `grafana-server.service` -- The default configuration specifies log file at `/var/log/grafana/grafana.log` -- The default configuration specifies sqlite3 db at `/var/lib/grafana/grafana.db` +- Copies init.d script to `/etc/init.d/grafana-server` +- Installs default file (environment vars) to `/etc/sysconfig/grafana-server` +- Copies configuration file to `/etc/grafana/grafana.ini` +- Installs systemd service (if systemd is available) name `grafana-server.service` +- The default configuration uses a log file at `/var/log/grafana/grafana.log` +- The default configuration specifies an sqlite3 database at `/var/lib/grafana/grafana.db` ## Start the server (init.d service) -- Start grafana by `sudo service grafana-server start` -- This will start the grafana-server process as the `grafana` user (created during package install) -- Default http port is `3000`, and default user is admin/admin -- To configure grafana server to start at boot time: `sudo /sbin/chkconfig --add grafana-server` +You can start Grafana by running: + + $ sudo service grafana-server start + +This will start the `grafana-server` process as the `grafana` user, +which is created during package installation. The default HTTP port is +`3000`, and default user and group is `admin`. + +To configure the Grafana server to start at boot time: + + $ sudo /sbin/chkconfig --add grafana-server ## Start the server (via systemd) @@ -70,29 +81,32 @@ The rpms are signed, you can verify the signature with this [public GPG key](htt $ systemctl start grafana-server $ systemctl status grafana-server -### Enable the systemd service (so grafana starts at boot) +### Enable the systemd service to start at boot sudo systemctl enable grafana-server.service ## Environment file -The systemd service file and init.d script both use the file located at `/etc/sysconfig/grafana-server` for -environment variables used when starting the backend. Here you can override log directory, data directory and other -variables. +The systemd service file and init.d script both use the file located at +`/etc/sysconfig/grafana-server` for environment variables used when +starting the back-end. Here you can override log directory, data +directory and other variables. ### Logging -By default grafana will log to `/var/log/grafana` +By default Grafana will log to `/var/log/grafana` ### Database -The default configuration specifies a sqlite3 database located at `/var/lib/grafana/grafana.db`. Please backup -this database before upgrades. You can also use mysql or postgres as the Grafana database. +The default configuration specifies a sqlite3 database located at +`/var/lib/grafana/grafana.db`. Please backup this database before +upgrades. You can also use MySQL or Postgres as the Grafana database. ## Configuration -The configuration file is located at `/etc/grafana/grafana.ini`. Go the [Configuration](configuration) page for details -on all those options. +The configuration file is located at `/etc/grafana/grafana.ini`. Go the +[Configuration](/installation/configuration) page for details on all +those options. ### Adding data sources diff --git a/docs/sources/installation/troubleshooting.md b/docs/sources/installation/troubleshooting.md index 9d0605f5eee..7a6f8a9d8ee 100644 --- a/docs/sources/installation/troubleshooting.md +++ b/docs/sources/installation/troubleshooting.md @@ -4,45 +4,70 @@ page_keywords: grafana, support, documentation # Troubleshooting -This page is dedicated to helping you solve any problem you have getting Grafana to work. Please review it before -opening a new github issue or asking a question in #grafana on freenode. +This page is dedicated to helping you solve any problem you have getting +Grafana to work. Please review it before opening a new [GitHub +issue](https://github.com/grafana/grafana/issues/new) or asking a +question in the `#grafana` IRC channel on freenode. ## General connection issues -When setting up Grafana for the first time you might experiance issues with Grafana being unable to query Graphite, OpenTSDB or InfluxDB. -You might not be able to get metric name completion or the graph might show an error like this: + +When setting up Grafana for the first time you might experience issues +with Grafana being unable to query Graphite, OpenTSDB or InfluxDB. You +might not be able to get metric name completion or the graph might show +an error like this: ![](/img/v1/graph_timestore_error.png) -For some type of errors the ``View details`` link will show you error details. But for many types of HTTP connection errors there is -very little information. The best way to troubleshoot these issues is use -[Chrome developer tools](https://developer.chrome.com/devtools/index). By pressing F12 you can bring up the chrome dev tools. +For some type of errors the `View details` link will show you error +details. But for many types of HTTP connection errors there is very +little information. The best way to troubleshoot these issues is use +the [Chrome developer tools](https://developer.chrome.com/devtools/index). +By pressing `F12` you can bring up the chrome dev tools. ![](/img/v1/toubleshooting_chrome_dev_tools.png) -There are two important tabs in the chrome dev tools, ``Network`` and ``Console``. Console will show you javascript errors and HTTP -request errors. In the Network tab you will be able to identifiy the request that failed and review request and response parameters. -This information will be of great help in finding the cause of the error. If you are unable to solve the issue, even after reading -the remainder of this troubleshooting guide, you may open a [github support issue](https://github.com/grafana/grafana/issues). -Before you do that please search the existing closed or open issues. Also if you need to create a support issue, -screenshots and or text information about the chrome console error, request and response information from the network tab in chrome -developer tools are of great help. +There are two important tabs in the Chrome developer tools: `Network` +and `Console`. The `Console` tab will show you Javascript errors and +HTTP request errors. In the Network tab you will be able to identify the +request that failed and review request and response parameters. This +information will be of great help in finding the cause of the error. + +If you are unable to solve the issue, even after reading the remainder +of this troubleshooting guide, you should open a [GitHub support +issue](https://github.com/grafana/grafana/issues). Before you do that +please search the existing closed or open issues. Also if you need to +create a support issue, screen shots and or text information about the +chrome console error, request and response information from the +`Network` tab in Chrome developer tools are of great help. ### Inspecting Grafana metric requests + ![](/img/v1/toubleshooting_chrome_dev_tools_network.png) -After open chrome developer tools for the first time the Network tab is empty you need to refresh the page to get requests to show. -For some type of errors (CORS related) there might not be a response at all. +After opening the Chrome developer tools for the first time the +`Network` tab is empty. You will need to refresh the page to get +requests to show. For some type of errors, especially CORS-related, +there might not be a response at all. ## Graphite connection issues -If your Graphite web server is on another domain or IP than your Grafana web server you will need to [setup -CORS](../install/#graphite-server-config) (Cross Origin Resource Sharing). -You know if you are having CORS related issues if you get an error like this in chrome developer tools: +If your Graphite web server is on another domain or IP address from your +Grafana web server you will need to [setup +CORS](../install/#graphite-server-config) (Cross Origin Resource +Sharing). + +You know if you are having CORS-related issues if you get an error like +this in the Chrome developer tools: ![](/img/v1/toubleshooting_graphite_cors_error.png) -If the request failed on method ``OPTIONS`` then you need to review your graphite web server configuration. +If the request failed on method `OPTIONS` then you need to review your +Graphite web server configuration. ## Only blank white page -When you load Grafana and all you get is a blank white page then you probably have a javascript syntax error in ``config.js``. -In chrome developer tools console you will quickly identify the line of the syntax error. + +When you load Grafana and all you get is a blank white page then you +probably have a Javascript syntax error in `config.js`. In the Chrome +developer tools console you will quickly identify the line of the syntax +error. + diff --git a/docs/sources/installation/windows.md b/docs/sources/installation/windows.md index 5bce40b3233..97d128f61a0 100644 --- a/docs/sources/installation/windows.md +++ b/docs/sources/installation/windows.md @@ -13,21 +13,30 @@ Description | Download Zip package for Windows | [grafana.2.0.2.windows-x64.zip](https://grafanarel.s3.amazonaws.com/winbuilds/dist/grafana-2.0.2.windows-x64.zip) ## Configure -The zip file contains a folder with the current grafana version. Extract this folder to anywhere you want Grafana to run from. -Go into the `conf` directory and copy `sample.ini` to `custom.ini`. You should edit `custom.ini`, never `defaults.ini`. -The default grafana port is `3000`, this port requires extra permissions on windows. Edit `custom.ini` and uncomment the `http_port` -config and change it to something like `8080` or similar. That port should not require extra windows privileges. +The zip file contains a folder with the current Grafana version. Extract +this folder to anywhere you want Grafana to run from. Go into the +`conf` directory and copy `sample.ini` to `custom.ini`. You should edit +`custom.ini`, never `defaults.ini`. -Start grafana by executing `grafana-server.exe`, preferbly from the command line. If you want to run Grafana as -windows service, download [NSSM](https://nssm.cc/). It is very easy add grafana as windows service using that tool. +The default Grafana port is `3000`, this port requires extra permissions +on windows. Edit `custom.ini` and uncomment the `http_port` +configuration option and change it to something like `8080` or similar. +That port should not require extra Windows privileges. -Read more about the [configuration options](configuration.md). +Start Grafana by executing `grafana-server.exe`, preferably from the +command line. If you want to run Grafana as windows service, download +[NSSM](https://nssm.cc/). It is very easy add Grafana as a Windows +service using that tool. + +Read more about the [configuration options](/installation/configuration). ## Building on Windows -The Grafana backend includes Sqlite3 which requires GCC to compile. So in order to compile Grafana on windows you need -to install GCC. We recommend [TDM-GCC](http://tdm-gcc.tdragon.net/download). +The Grafana backend includes Sqlite3 which requires GCC to compile. So +in order to compile Grafana on Windows you need to install GCC. We +recommend [TDM-GCC](http://tdm-gcc.tdragon.net/download). -Copy conf/sample.ini to a file named conf/custom.ini and change the web server port to something like 8080. The default -Grafana port(3000) requires special privileges on Windows. +Copy `conf/sample.ini` to a file named `conf/custom.ini` and change the +web server port to something like 8080. The default Grafana port, 3000, +requires special privileges on Windows. diff --git a/docs/sources/project/building_from_source.md b/docs/sources/project/building_from_source.md index 8466dbd2736..ee8d6e66c45 100644 --- a/docs/sources/project/building_from_source.md +++ b/docs/sources/project/building_from_source.md @@ -17,6 +17,7 @@ dev environment. ## Get Code ``` +export GOPATH=`pwd` go get github.com/grafana/grafana ``` @@ -24,11 +25,11 @@ go get github.com/grafana/grafana ``` cd $GOPATH/src/github.com/grafana/grafana go run build.go setup (only needed once to install godep) -godep restore (will pull down all golang lib dependecies in your current GOPATH) +$GOPATH/bin/godep restore (will pull down all golang lib dependecies in your current GOPATH) go build . ``` -# Building on Windows +## Building on Windows The Grafana backend includes Sqlite3 which requires GCC to compile. So in order to compile Grafana on windows you need to install GCC. We recommend [TDM-GCC](http://tdm-gcc.tdragon.net/download). @@ -58,6 +59,8 @@ bra run Open grafana in your browser (default http://localhost:3000) and login with admin user (default user/pass = admin/admin). ## Creating optimized release packages +This step builds linux packages and requires that fpm is installed. Install fpm via `gem install fpm`. + ``` go run build.go build package ``` @@ -72,4 +75,4 @@ You only need to add the options you want to override. Config files are applied ## Create a pull requests -Before or after your create a pull requests, sign the [contributor license aggrement](/docs/contributing/cla.html). +Before or after your create a pull requests, sign the [contributor license agreement](/project/cla.html). diff --git a/docs/sources/reference/annotations.md b/docs/sources/reference/annotations.md index 41d7bf411a3..b0e84ef762b 100644 --- a/docs/sources/reference/annotations.md +++ b/docs/sources/reference/annotations.md @@ -13,14 +13,14 @@ you can get title, tags, and text information for the event. To add an annotation query click dashboard settings icon in top menu and select `Annotations` from the dropdown. This will open the `Annotations` edit view. Click the `Add` tab to add a new annotation query. -### Graphite annotations +## Graphite annotations Graphite supports two ways to query annotations. - A regular metric query, use the `Graphite target expression` text input for this -- Graphite events query, use the `Graphite event tags` text input, especify an tag or wildcard (leave empty should also work) +- Graphite events query, use the `Graphite event tags` text input, specify an tag or wildcard (leave empty should also work) -## Elasticsearch annoations +## Elasticsearch annotations ![](/img/v2/annotations_es.png) Grafana can query any Elasticsearch index for annotation events. The index name can be the name of an alias or an index wildcard pattern. @@ -36,5 +36,4 @@ as the name for the fields that should be used for the annotation title, tags an For InfluxDB you need to enter a query like in the above screenshot. You need to have the ```where $timeFilter``` part. If you only select one column you will not need to enter anything in the column mapping fields. -If you have multiple columns you need to specify which column should be treated as title, tags and text column. diff --git a/docs/sources/reference/dashlist.md b/docs/sources/reference/dashlist.md index 6f591169f2c..9a54a18e288 100644 --- a/docs/sources/reference/dashlist.md +++ b/docs/sources/reference/dashlist.md @@ -6,4 +6,22 @@ page_keywords: grafana, dashlist, panel, documentation # Dashlist Panel +## Overview +![](/img/v2/dashboard_list_panel.png) + +The dashboard list panel allows you to show a list of links to other dashboards. The list +can be based on a search query or dashboard tag query. You can also configure it to show your starred +dashboards. + +## Options +![](/img/v2/dashboard_list_panel_options.png) + +Name | Description +------------ | ------------- +Mode | Set search or starred mode +Query | If in search mode specify the search query +Tags | if in search mode specify dashboard tags to search for +Limit number to | Specify the maximum number of dashboards + + diff --git a/docs/sources/reference/graph.md b/docs/sources/reference/graph.md index 61bebd6b37b..9de23332a99 100644 --- a/docs/sources/reference/graph.md +++ b/docs/sources/reference/graph.md @@ -62,7 +62,7 @@ The ``Left Y`` and ``Right Y`` can be customized using: - ``Unit`` - The display unit for the Y value - ``Grid Max`` - The maximum Y value. (default auto) -- ``Grid Min`` - The minium Y value. (default auto) +- ``Grid Min`` - The minimum Y value. (default auto) - ``Label`` - The Y axis label (default "") Axes can also be hidden by unchecking the appropriate box from `Show Axis`. diff --git a/docs/sources/reference/http_api.md b/docs/sources/reference/http_api.md index f02741cb37b..fb9e7600ecf 100644 --- a/docs/sources/reference/http_api.md +++ b/docs/sources/reference/http_api.md @@ -53,7 +53,7 @@ Creates a new dashboard or updates an existing dashboard. "rows": [ { } - ] + ], "schemaVersion": 6, "version": 0 }, @@ -84,8 +84,8 @@ Status Codes: - **401** – Unauthorized - **412** – Precondition failed -The **412** status code is used when a newer dashboard already exists (newer, its version is greater than the verison that was sent). The -same status code is also used if another dashboar exists with the same title. The response body will look like this: +The **412** status code is used when a newer dashboard already exists (newer, its version is greater than the version that was sent). The +same status code is also used if another dashboard exists with the same title. The response body will look like this: HTTP/1.1 412 Precondition Failed Content-Type: application/json; charset=UTF-8 @@ -121,7 +121,7 @@ Will return the dashboard given the dashboard slug. Slug is the url friendly ver "isStarred": false, "slug": "production-overview" }, - "dashboard": { + "model": { "id": null, "title": "Production Overview", "tags": [ "templated" ], @@ -141,12 +141,236 @@ Will return the dashboard given the dashboard slug. Slug is the url friendly ver The above will delete the dashboard with the specified slug. The slug is the url friendly (unique) version of the dashboard title. +### Gets the home dashboard + +`GET /api/dashboards/home` + +### Tags for Dashboard + +`GET /api/dashboards/tags` + +### Dashboard from JSON file + +`GET /file/:file` + +### Search Dashboards + +`GET /api/search/` + +Status Codes: + +- **query** – Search Query +- **tags** – Tags to use +- **starred** – Flag indicating if only starred Dashboards should be returned +- **tagcloud** - Flag indicating if a tagcloud should be returned + +**Example Request**: + + GET /api/search?query=MyDashboard&starred=true&tag=prod HTTP/1.1 + Accept: application/json + Content-Type: application/json + Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk + ## Data sources +### Get all datasources + +`GET /api/datasources` + +### Get a single data sources by Id + +`GET /api/datasources/:datasourceId` + ### Create data source -## Organizations +`POST /api/datasources` + +**Example Response**: + + HTTP/1.1 200 + Content-Type: application/json + + {"message":"Datasource added"} + +### Update an existing data source + +`PUT /api/datasources/:datasourceId` + +### Delete an existing data source + +`DELETE /api/datasources/:datasourceId` + +**Example Response**: + + HTTP/1.1 200 + Content-Type: application/json + + {"message":"Data source deleted"} + +### Available data source types + +`GET /api/datasources/plugins` + +## Data source proxy calls + +`GET /api/datasources/proxy/:datasourceId/*` + +Proxies all calls to the actual datasource. + +## Organisation + +### Get current Organisation + +`GET /api/org` + +### Get all users within the actual organisation + +`GET /api/org/users` + +### Add a new user to the actual organisation + +`POST /api/org/users` + +Adds a global user to the actual organisation. + +### Updates the given user + +`PATCH /api/org/users/:userId` + +### Delete user in actual organisation + +`DELETE /api/org/users/:userId` + +### Get all Users + +`GET /api/org/users` + +## Organisations + +### Search all Organisations + +`GET /api/orgs` + +### Update Organisation + +`PUT /api/orgs/:orgId` + +### Get Users in Organisation + +`GET /api/orgs/:orgId/users` + +### Add User in Organisation + +`POST /api/orgs/:orgId/users` + +### Update Users in Organisation + +`PATCH /api/orgs/:orgId/users/:userId` + +### Delete User in Organisation + +`DELETE /api/orgs/:orgId/users/:userId` ## Users +### Search Users +`GET /api/users` + +### Get single user by Id + +`GET /api/users/:id` + +### User Update + +`PUT /api/users/:id` + +### Get Organisations for user + +`GET /api/users/:id/orgs` + +## User + +### Change Password + +`PUT /api/user/password` + +Changes the password for the user + +### Actual User + +`GET /api/user` + +The above will return the current user. + +### Switch user context + +`POST /api/user/using/:organisationId` + +Switch user context to the given organisation. + +### Organisations of the actual User + +`GET /api/user/orgs` + +The above will return a list of all organisations of the current user. + +### Star a dashboard + +`POST /api/user/stars/dashboard/:dashboardId` + +Stars the given Dashboard for the actual user. + +### Unstar a dashboard + +`DELETE /api/user/stars/dashboard/:dashboardId` + +Deletes the staring of the given Dashboard for the actual user. + +## Snapshots + +### Create new snapshot + +`POST /api/snapshots` + +### Get Snapshot by Id + +`GET /api/snapshots/:key` + +### Delete Snapshot by Id + +`DELETE /api/snapshots-delete/:key` + +## Frontend Settings + +### Get Settings + +`GET /api/frontend/settings` + +## Login + +### Renew session based on remember cookie + +`GET /api/login/ping` + +## Admin + +### Settings + +`GET /api/admin/settings` + +### Global Users + +`POST /api/admin/users` + +### Password for User + +`PUT /api/admin/users/:id/password` + +### Permissions + +`PUT /api/admin/users/:id/permissions` + +### Delete global User + +`DELETE /api/admin/users/:id` diff --git a/docs/sources/reference/scripting.md b/docs/sources/reference/scripting.md index 45158eeadcd..d896a2c7650 100644 --- a/docs/sources/reference/scripting.md +++ b/docs/sources/reference/scripting.md @@ -12,7 +12,7 @@ With scripted dashboards you can dynamically create your dashboards using javasc under `public/dashboards/` there is a file named `scripted.js`. This file contains an example of a scripted dashboard. You can access it by using the url: `http://grafana_url/dashboard/script/scripted.js?rows=3&name=myName` -If you open scripted.js you can see how it reads url paramters from ARGS variable and then adds rows and panels. +If you open scripted.js you can see how it reads url parameters from ARGS variable and then adds rows and panels. ## Example diff --git a/docs/sources/reference/singlestat.md b/docs/sources/reference/singlestat.md index 8ca68dd82b6..4e5a38544c5 100644 --- a/docs/sources/reference/singlestat.md +++ b/docs/sources/reference/singlestat.md @@ -11,5 +11,48 @@ page_keywords: grafana, singlestat, panel, documentation The singlestat panel allows you to show the one main summery stat of a single series (like max, min, avg, sum). It also provides thresholds to color that singlestat metric or the panel background. -## Options -- TODO +### Big Value Configuration + +The big value configuration allows you to both customize the look of your singlestat metric, as well as add additional labels to contexualize the metric. + + + +1. `Big Value`: Big Value refers to the collection of values displayed in the singlestat panel. +2. `Prefixes`: The Prefix fields let you define a custom label and font-size (as a %) to appear *before* the singlestat metric. +3. `Values`: The Value fields let you set the (min, max, average, current, total) and font-size (as a %) of the singlestat metric. +4. `Potsfixes`: The Postfix fields let you define a custom label and font-size (as a %) to appear *after* the singlestat metric. +5. `Units`: Units are appended to the the singlestat metric within the panel, and will respect the color and threshold settings for the Value. +6. `Decimals`: The Decimal field allows you to override automatic decimal precision, inceasing the digits displayed for your singlestat metric. + +### Coloring + +The coloring options of the singlestat config allow you to dynamically change the colors based on the displayed data. + + + +1. `Background`: The Background checkbox applies the configured thresholds and colors to the entirity of the singlestat panel background. +2. `Value`: The Value checkbox applies the configured thresholds and colors to the value within the singlestat panel. +3. `Thresholds`: Thresholds allow you to change the background and value colors dyanmically within the panel. The threshold field accepts **3 comma-separated** values, corresponding to the three colors directly to the right. +4. `Colors`: The color picker allows you to select a color and opacity +5. `Invert order`: This link toggles the threshold color order.
For example: Green, Orange, Red () will become Red, Orange, Green (). + +### Spark Lines + +Spark lines are a great way of seeing the historical data associated with a single stat value, providing valuable context at a glance. Spark lines act differently than traditional graph panels and do not include x or y axis, coordinates, a legend, or ability to interact with the graph. + + + +1. `Show`: The show checkbox will toggle whether the spark line is shown in the panel. When unselected, only the value will appear. +2. `Background`: Check if you want the sparklines to take up the full panel width or uncheck if they should only be at the bottom. +3. `Line Color`: This color selection applies to the color of the sparkline itself. +4. `Fill Color`: This color selection applies to the area below the sparkline. + +> ***Pro-tip:*** Reduce the opacity on fill colors for nice looking panels. + +### Value to text mapping + +Value to text mapping allows you to translate values into explcit text. The text will respect all styling, thresholds and customization defined for the value. + + + + diff --git a/docs/sources/reference/templating.md b/docs/sources/reference/templating.md index f66a8838441..397aaddec91 100644 --- a/docs/sources/reference/templating.md +++ b/docs/sources/reference/templating.md @@ -5,14 +5,43 @@ page_keywords: grafana, templating, variables, guide, documentation --- # Templated Dashboards +![](/img/v2/templating_var_list.png) -Templating feature can be enabled under dashboard settings, in the Features tab. The templating feature allows -you to create variables that can be used in your metric queries, series names and panel titles. Use this feature to -create generic dashboards that can quickly be changed to show graphs for different servers or metrics. +## Overview +Templating allows you to create dashboard variables that can be used in your metric queries, series +names and panel titles. Use this feature to create generic dashboards that can quickly be +changed to show graphs for different servers or metrics. + +You find this feature in the dashboard cog dropdown menu. + +## Variable types +There are three different types of template variables. They can all be used in the +same way but they differ in how the list variables values is created. + +### Query +This is the most common type of variable. It allows you to create a variable +with values fetched directly from a data source via a metric exploration query. + +For example a query like `prod.servers.*` will fill the variable with all possible +values that exists in the wildcard position (Graphite example). + +You can also create nested variables that use other variables in their definition. For example +`apps.$app.servers.*` uses the variable `$app` in its query definition. + +> For examples of template queries appropriate for your data source checkout the documentation +> page for your data source. + +### Interval +This variable type is useful for time ranges like `1m`,`1h`, `1d`. There is also an auto +option that will change depending on the current time range, you can specify how many times +the current time range should be divided to calculate the current `auto` range. + +![](/img/v2/templated_variable_parameter.png) + +### Custom +This variable type allow you to manually specify all the different values as a comma seperated +string. ## Screencast - Templated Graphite Queries -
-## Screencast - Templated InfluxDB Queries -Coming soon diff --git a/docs/sources/reference/timerange.md b/docs/sources/reference/timerange.md index 4f5f4659594..d2f05f5a9d6 100644 --- a/docs/sources/reference/timerange.md +++ b/docs/sources/reference/timerange.md @@ -24,7 +24,7 @@ All of this applies to all Panels in the Dashboard (except those with Panel Time It's possible to customize the options displayed for relative time and the auto-refresh options. -From Dashboard setttings, click the Timepicker tab. From here you can specify the relative and auto refresh intervals. The Timepicker tab settings are saved on a per Dashboard basis. +From Dashboard settings, click the Timepicker tab. From here you can specify the relative and auto refresh intervals. The Timepicker tab settings are saved on a per Dashboard basis. Entries are comma separated and accept a number followed by one of the following units: s (seconds), m (minutes), h (hours), d (days), w (weeks), M (months), y (years). ![](/img/v1/timepicker_editor.png) diff --git a/docs/sources/versions.html_fragment b/docs/sources/versions.html_fragment index 00ee000a0f0..55190af8b37 100644 --- a/docs/sources/versions.html_fragment +++ b/docs/sources/versions.html_fragment @@ -1,2 +1,3 @@ +
  • Version v2.1
  • Version v2.0
  • Version v1.9
  • diff --git a/latest.json b/latest.json index a6613c11634..a85d79df539 100644 --- a/latest.json +++ b/latest.json @@ -1,3 +1,3 @@ { - "version": "2.0.1", + "version": "2.0.2" } diff --git a/main.go b/main.go index ffe7e3d7cd3..a732e1a166f 100644 --- a/main.go +++ b/main.go @@ -15,6 +15,8 @@ import ( "github.com/grafana/grafana/pkg/metrics" "github.com/grafana/grafana/pkg/plugins" "github.com/grafana/grafana/pkg/services/eventpublisher" + "github.com/grafana/grafana/pkg/services/notifications" + "github.com/grafana/grafana/pkg/services/search" "github.com/grafana/grafana/pkg/services/sqlstore" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/social" @@ -48,13 +50,18 @@ func main() { flag.Parse() - initRuntime() writePIDFile() + initRuntime() + search.Init() social.NewOAuthService() eventpublisher.Init() plugins.Init() + if err := notifications.Init(); err != nil { + log.Fatal(3, "Notification service failed to initialize", err) + } + if setting.ReportingEnabled { go metrics.StartUsageReportLoop() } diff --git a/package.json b/package.json index 0ff6bf8b480..4200da6951a 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "company": "Coding Instinct AB" }, "name": "grafana", - "version": "2.0.2", + "version": "2.1.0-pre1", "repository": { "type": "git", "url": "http://github.com/torkelo/grafana.git" @@ -34,22 +34,19 @@ "grunt-string-replace": "~0.2.4", "grunt-usemin": "3.0.0", "jshint-stylish": "~0.1.5", - "karma": "~0.12.21", + "karma": "~0.12.31", "karma-chrome-launcher": "~0.1.4", "karma-coffee-preprocessor": "~0.1.2", - "karma-coverage": "^0.2.5", - "karma-coveralls": "^0.1.4", + "karma-coverage": "0.3.1", + "karma-coveralls": "0.1.5", "karma-expect": "~1.1.0", - "karma-firefox-launcher": "~0.1.3", - "karma-html2js-preprocessor": "~0.1.0", - "karma-jasmine": "~0.2.2", - "karma-mocha": "~0.1.4", - "karma-phantomjs-launcher": "~0.1.4", - "karma-requirejs": "~0.2.1", - "karma-script-launcher": "~0.1.0", - "load-grunt-tasks": "~0.2.0", - "mocha": "~1.16.1", - "requirejs": "~2.1.14", + "karma-mocha": "~0.1.10", + "karma-phantomjs-launcher": "0.1.4", + "karma-requirejs": "0.2.2", + "karma-script-launcher": "0.1.0", + "load-grunt-tasks": "0.2.0", + "mocha": "2.2.4", + "requirejs": "2.1.17", "rjs-build-analysis": "0.0.3" }, "engines": { @@ -65,6 +62,6 @@ "grunt-jscs": "~1.5.x", "karma-sinon": "^1.0.3", "lodash": "^2.4.1", - "sinon": "^1.10.3" + "sinon": "1.10.3" } } diff --git a/packaging/deb/control/postinst b/packaging/deb/control/postinst index edb163ba7fb..7585d3f879d 100755 --- a/packaging/deb/control/postinst +++ b/packaging/deb/control/postinst @@ -43,9 +43,9 @@ case "$1" in chmod 755 /var/log/grafana /var/lib/grafana # configuration files should not be modifiable by grafana user, as this can be a security issue - chown -Rh root:root /etc/grafana/* + chown -Rh root:$GRAFANA_GROUP /etc/grafana/* chmod 755 /etc/grafana - find /etc/grafana -type f -exec chmod 644 {} ';' + find /etc/grafana -type f -exec chmod 640 {} ';' find /etc/grafana -type d -exec chmod 755 {} ';' # if $2 is set, this is an upgrade diff --git a/packaging/deb/init.d/grafana-server b/packaging/deb/init.d/grafana-server index 6daebdb4331..a4f6423a68d 100755 --- a/packaging/deb/init.d/grafana-server +++ b/packaging/deb/init.d/grafana-server @@ -38,7 +38,12 @@ DAEMON=/usr/sbin/$NAME if [ `id -u` -ne 0 ]; then echo "You need root privileges to run this script" - exit 1 + exit 4 +fi + +if [ ! -x $DAEMON ]; then + echo "Program not installed or not executable" + exit 5 fi . /lib/lsb/init-functions @@ -54,9 +59,6 @@ fi DAEMON_OPTS="--pidfile=${PID_FILE} --config=${CONF_FILE} cfg:default.paths.data=${DATA_DIR} cfg:default.paths.logs=${LOG_DIR}" -# Check DAEMON exists -test -x $DAEMON || exit 0 - case "$1" in start) @@ -137,8 +139,6 @@ case "$1" in ;; *) log_success_msg "Usage: $0 {start|stop|restart|force-reload|status}" - exit 1 + exit 3 ;; esac - -exit 0 diff --git a/packaging/rpm/control/postinst b/packaging/rpm/control/postinst index 9e5e9accf79..fce80719115 100644 --- a/packaging/rpm/control/postinst +++ b/packaging/rpm/control/postinst @@ -43,9 +43,9 @@ if [ $1 -eq 1 ] ; then chmod 755 /var/log/grafana /var/lib/grafana # configuration files should not be modifiable by grafana user, as this can be a security issue - chown -Rh root:root /etc/grafana/* + chown -Rh root:$GRAFANA_GROUP /etc/grafana/* chmod 755 /etc/grafana - find /etc/grafana -type f -exec chmod 644 {} ';' + find /etc/grafana -type f -exec chmod 640 {} ';' find /etc/grafana -type d -exec chmod 755 {} ';' if [ -x /bin/systemctl ] ; then diff --git a/packaging/rpm/init.d/grafana-server b/packaging/rpm/init.d/grafana-server index 96e0c18c5e4..92e88673d74 100755 --- a/packaging/rpm/init.d/grafana-server +++ b/packaging/rpm/init.d/grafana-server @@ -35,6 +35,16 @@ MAX_OPEN_FILES=10000 PID_FILE=/var/run/$NAME.pid DAEMON=/usr/sbin/$NAME +if [ `id -u` -ne 0 ]; then + echo "You need root privileges to run this script" + exit 4 +fi + +if [ ! -x $DAEMON ]; then + echo "Program not installed or not executable" + exit 5 +fi + # # init.d / servicectl compatibility (openSUSE) # @@ -55,9 +65,6 @@ fi DAEMON_OPTS="--pidfile=${PID_FILE} --config=${CONF_FILE} cfg:default.paths.data=${DATA_DIR} cfg:default.paths.logs=${LOG_DIR}" -# Check DAEMON exists -test -x $DAEMON || exit 0 - function isRunning() { status -p $PID_FILE $NAME > /dev/null 2>&1 } @@ -69,7 +76,7 @@ case "$1" in isRunning if [ $? -eq 0 ]; then echo "Already running." - exit 2 + exit 0 fi # Prepare environment @@ -82,7 +89,7 @@ case "$1" in # Start Daemon cd $GRAFANA_HOME - su -s /bin/sh -c "nohup ${DAEMON} ${DAEMON_OPTS} >> /dev/null 3>&1 &" $GRAFANA_USER + su -s /bin/sh -c "nohup ${DAEMON} ${DAEMON_OPTS} >> /dev/null 3>&1 &" $GRAFANA_USER 2> /dev/null return=$? if [ $return -eq 0 ] then @@ -90,7 +97,7 @@ case "$1" in # check if pid file has been written two if ! [[ -s $PID_FILE ]]; then echo "FAILED" - exit 3 + exit 1 fi i=0 timeout=10 @@ -101,7 +108,7 @@ case "$1" in i=$(($i + 1)) if [ $i -gt $timeout ]; then echo "FAILED" - exit 4 + exit 1 fi done fi @@ -131,6 +138,7 @@ case "$1" in ;; status) status -p $PID_FILE $NAME + exit $? ;; restart|force-reload) if [ -f "$PID_FILE" ]; then @@ -141,8 +149,6 @@ case "$1" in ;; *) echo -n "Usage: $0 {start|stop|restart|force-reload|status}" - exit 1 + exit 3 ;; esac - -exit 0 diff --git a/pkg/api/admin_settings.go b/pkg/api/admin_settings.go index 21615219acd..06413d6a0b1 100644 --- a/pkg/api/admin_settings.go +++ b/pkg/api/admin_settings.go @@ -17,7 +17,7 @@ func AdminGetSettings(c *middleware.Context) { for _, key := range section.Keys() { keyName := key.Name() value := key.Value() - if strings.Contains(keyName, "secret") || strings.Contains(keyName, "password") { + if strings.Contains(keyName, "secret") || strings.Contains(keyName, "password") || (strings.Contains(keyName, "provider_config") && strings.Contains(value, "@")) { value = "************" } diff --git a/pkg/api/admin_users.go b/pkg/api/admin_users.go index f7e8fca2b5e..a293032c200 100644 --- a/pkg/api/admin_users.go +++ b/pkg/api/admin_users.go @@ -9,36 +9,6 @@ import ( "github.com/grafana/grafana/pkg/util" ) -func AdminSearchUsers(c *middleware.Context) { - query := m.SearchUsersQuery{Query: "", Page: 0, Limit: 1000} - if err := bus.Dispatch(&query); err != nil { - c.JsonApiErr(500, "Failed to fetch users", err) - return - } - - c.JSON(200, query.Result) -} - -func AdminGetUser(c *middleware.Context) { - userId := c.ParamsInt64(":id") - - query := m.GetUserByIdQuery{Id: userId} - - if err := bus.Dispatch(&query); err != nil { - c.JsonApiErr(500, "Failed to fetch user", err) - return - } - - result := dtos.AdminUserListItem{ - Name: query.Result.Name, - Email: query.Result.Email, - Login: query.Result.Login, - IsGrafanaAdmin: query.Result.IsAdmin, - } - - c.JSON(200, result) -} - func AdminCreateUser(c *middleware.Context, form dtos.AdminCreateUserForm) { cmd := m.CreateUserCommand{ Login: form.Login, @@ -70,32 +40,6 @@ func AdminCreateUser(c *middleware.Context, form dtos.AdminCreateUserForm) { c.JsonOK("User created") } -func AdminUpdateUser(c *middleware.Context, form dtos.AdminUpdateUserForm) { - userId := c.ParamsInt64(":id") - - cmd := m.UpdateUserCommand{ - UserId: userId, - Login: form.Login, - Email: form.Email, - Name: form.Name, - } - - if len(cmd.Login) == 0 { - cmd.Login = cmd.Email - if len(cmd.Login) == 0 { - c.JsonApiErr(400, "Validation error, need specify either username or email", nil) - return - } - } - - if err := bus.Dispatch(&cmd); err != nil { - c.JsonApiErr(500, "failed to update user", err) - return - } - - c.JsonOK("User updated") -} - func AdminUpdateUserPassword(c *middleware.Context, form dtos.AdminUpdateUserPasswordForm) { userId := c.ParamsInt64(":id") diff --git a/pkg/api/api.go b/pkg/api/api.go index 6482beb075c..679c0d1f760 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -13,7 +13,7 @@ func Register(r *macaron.Macaron) { reqSignedIn := middleware.Auth(&middleware.AuthOptions{ReqSignedIn: true}) reqGrafanaAdmin := middleware.Auth(&middleware.AuthOptions{ReqSignedIn: true, ReqGrafanaAdmin: true}) reqEditorRole := middleware.RoleAuth(m.ROLE_EDITOR, m.ROLE_ADMIN) - reqAccountAdmin := middleware.RoleAuth(m.ROLE_ADMIN) + regOrgAdmin := middleware.RoleAuth(m.ROLE_ADMIN) bind := binding.Bind // not logged in views @@ -26,6 +26,7 @@ func Register(r *macaron.Macaron) { // authed views r.Get("/profile/", reqSignedIn, Index) r.Get("/org/", reqSignedIn, Index) + r.Get("/org/new", reqSignedIn, Index) r.Get("/datasources/", reqSignedIn, Index) r.Get("/datasources/edit/*", reqSignedIn, Index) r.Get("/org/users/", reqSignedIn, Index) @@ -39,7 +40,14 @@ func Register(r *macaron.Macaron) { // sign up r.Get("/signup", Index) - r.Post("/api/user/signup", bind(m.CreateUserCommand{}), SignUp) + r.Post("/api/user/signup", bind(m.CreateUserCommand{}), wrap(SignUp)) + + // reset password + r.Get("/user/password/send-reset-email", Index) + r.Get("/user/password/reset", Index) + + r.Post("/api/user/password/send-reset-email", bind(dtos.SendResetPasswordEmailForm{}), wrap(SendResetPasswordEmail)) + r.Post("/api/user/password/reset", bind(dtos.ResetUserPasswordForm{}), wrap(ResetPassword)) // dashboard snapshots r.Post("/api/snapshots/", bind(m.CreateDashboardSnapshotCommand{}), CreateDashboardSnapshot) @@ -53,53 +61,79 @@ func Register(r *macaron.Macaron) { // authed api r.Group("/api", func() { - // user + + // user (signed in) r.Group("/user", func() { - r.Get("/", GetUser) - r.Put("/", bind(m.UpdateUserCommand{}), UpdateUser) - r.Post("/using/:id", UserSetUsingOrg) - r.Get("/orgs", GetUserOrgList) - r.Post("/stars/dashboard/:id", StarDashboard) - r.Delete("/stars/dashboard/:id", UnstarDashboard) - r.Put("/password", bind(m.ChangeUserPasswordCommand{}), ChangeUserPassword) + r.Get("/", wrap(GetSignedInUser)) + r.Put("/", bind(m.UpdateUserCommand{}), wrap(UpdateSignedInUser)) + r.Post("/using/:id", wrap(UserSetUsingOrg)) + r.Get("/orgs", wrap(GetSignedInUserOrgList)) + r.Post("/stars/dashboard/:id", wrap(StarDashboard)) + r.Delete("/stars/dashboard/:id", wrap(UnstarDashboard)) + r.Put("/password", bind(m.ChangeUserPasswordCommand{}), wrap(ChangeUserPassword)) }) - // account + // users (admin permission required) + r.Group("/users", func() { + r.Get("/", wrap(SearchUsers)) + r.Get("/:id", wrap(GetUserById)) + r.Get("/:id/orgs", wrap(GetUserOrgList)) + r.Put("/:id", bind(m.UpdateUserCommand{}), wrap(UpdateUser)) + }, reqGrafanaAdmin) + + // current org r.Group("/org", func() { - r.Get("/", GetOrg) - r.Post("/", bind(m.CreateOrgCommand{}), CreateOrg) - r.Put("/", bind(m.UpdateOrgCommand{}), UpdateOrg) - r.Post("/users", bind(m.AddOrgUserCommand{}), AddOrgUser) - r.Get("/users", GetOrgUsers) - r.Delete("/users/:id", RemoveOrgUser) - }, reqAccountAdmin) + r.Get("/", wrap(GetOrgCurrent)) + r.Put("/", bind(m.UpdateOrgCommand{}), wrap(UpdateOrgCurrent)) + r.Post("/users", bind(m.AddOrgUserCommand{}), wrap(AddOrgUserToCurrentOrg)) + r.Get("/users", wrap(GetOrgUsersForCurrentOrg)) + r.Patch("/users/:userId", bind(m.UpdateOrgUserCommand{}), wrap(UpdateOrgUserForCurrentOrg)) + r.Delete("/users/:userId", wrap(RemoveOrgUserForCurrentOrg)) + }, regOrgAdmin) + + // create new org + r.Post("/orgs", bind(m.CreateOrgCommand{}), wrap(CreateOrg)) + + // search all orgs + r.Get("/orgs", reqGrafanaAdmin, wrap(SearchOrgs)) + + // orgs (admin routes) + r.Group("/orgs/:orgId", func() { + r.Put("/", bind(m.UpdateOrgCommand{}), wrap(UpdateOrg)) + r.Get("/users", wrap(GetOrgUsers)) + r.Post("/users", bind(m.AddOrgUserCommand{}), wrap(AddOrgUser)) + r.Patch("/users/:userId", bind(m.UpdateOrgUserCommand{}), wrap(UpdateOrgUser)) + r.Delete("/users/:userId", wrap(RemoveOrgUser)) + }, reqGrafanaAdmin) // auth api keys r.Group("/auth/keys", func() { - r.Get("/", GetApiKeys) - r.Post("/", bind(m.AddApiKeyCommand{}), AddApiKey) - r.Delete("/:id", DeleteApiKey) - }, reqAccountAdmin) + r.Get("/", wrap(GetApiKeys)) + r.Post("/", bind(m.AddApiKeyCommand{}), wrap(AddApiKey)) + r.Delete("/:id", wrap(DeleteApiKey)) + }, regOrgAdmin) // Data sources r.Group("/datasources", func() { - r.Combo("/"). - Get(GetDataSources). - Put(bind(m.AddDataSourceCommand{}), AddDataSource). - Post(bind(m.UpdateDataSourceCommand{}), UpdateDataSource) + r.Get("/", GetDataSources) + r.Post("/", bind(m.AddDataSourceCommand{}), AddDataSource) + r.Put("/:id", bind(m.UpdateDataSourceCommand{}), UpdateDataSource) r.Delete("/:id", DeleteDataSource) r.Get("/:id", GetDataSourceById) r.Get("/plugins", GetDataSourcePlugins) - }, reqAccountAdmin) + }, regOrgAdmin) r.Get("/frontend/settings/", GetFrontendSettings) r.Any("/datasources/proxy/:id/*", reqSignedIn, ProxyDataSourceRequest) + r.Any("/datasources/proxy/:id", reqSignedIn, ProxyDataSourceRequest) // Dashboard r.Group("/dashboards", func() { r.Combo("/db/:slug").Get(GetDashboard).Delete(DeleteDashboard) r.Post("/db", reqEditorRole, bind(m.SaveDashboardCommand{}), PostDashboard) + r.Get("/file/:file", GetDashboardFromJsonFile) r.Get("/home", GetHomeDashboard) + r.Get("/tags", GetDashboardTags) }) // Search @@ -112,10 +146,7 @@ func Register(r *macaron.Macaron) { // admin api r.Group("/api/admin", func() { r.Get("/settings", AdminGetSettings) - r.Get("/users", AdminSearchUsers) - r.Get("/users/:id", AdminGetUser) r.Post("/users", bind(dtos.AdminCreateUserForm{}), AdminCreateUser) - r.Put("/users/:id/details", bind(dtos.AdminUpdateUserForm{}), AdminUpdateUser) r.Put("/users/:id/password", bind(dtos.AdminUpdateUserPasswordForm{}), AdminUpdateUserPassword) r.Put("/users/:id/permissions", bind(dtos.AdminUpdateUserPermissionsForm{}), AdminUpdateUserPermissions) r.Delete("/users/:id", AdminDeleteUser) @@ -124,5 +155,5 @@ func Register(r *macaron.Macaron) { // rendering r.Get("/render/*", reqSignedIn, RenderToPng) - r.NotFound(NotFound) + r.NotFound(NotFoundHandler) } diff --git a/pkg/api/api_test.go b/pkg/api/api_test.go new file mode 100644 index 00000000000..ea3588b0e67 --- /dev/null +++ b/pkg/api/api_test.go @@ -0,0 +1,35 @@ +package api + +import ( + "testing" +) + +func TestHttpApi(t *testing.T) { + + // Convey("Given the grafana api", t, func() { + // ConveyApiScenario("Can sign up", func(c apiTestContext) { + // c.PostJson() + // So(c.Resp, ShouldEqualJsonApiResponse, "User created and logged in") + // }) + // + // m := macaron.New() + // m.Use(middleware.GetContextHandler()) + // m.Use(middleware.Sessioner(&session.Options{})) + // Register(m) + // + // var context *middleware.Context + // m.Get("/", func(c *middleware.Context) { + // context = c + // }) + // + // resp := httptest.NewRecorder() + // req, err := http.NewRequest("GET", "/", nil) + // So(err, ShouldBeNil) + // + // m.ServeHTTP(resp, req) + // + // Convey("should red 200", func() { + // So(resp.Code, ShouldEqual, 200) + // }) + // }) +} diff --git a/pkg/api/apikey.go b/pkg/api/apikey.go index 237fdc48ab0..b2097104aba 100644 --- a/pkg/api/apikey.go +++ b/pkg/api/apikey.go @@ -8,12 +8,11 @@ import ( m "github.com/grafana/grafana/pkg/models" ) -func GetApiKeys(c *middleware.Context) { +func GetApiKeys(c *middleware.Context) Response { query := m.GetApiKeysQuery{OrgId: c.OrgId} if err := bus.Dispatch(&query); err != nil { - c.JsonApiErr(500, "Failed to list api keys", err) - return + return ApiError(500, "Failed to list api keys", err) } result := make([]*m.ApiKeyDTO, len(query.Result)) @@ -24,27 +23,26 @@ func GetApiKeys(c *middleware.Context) { Role: t.Role, } } - c.JSON(200, result) + + return Json(200, result) } -func DeleteApiKey(c *middleware.Context) { +func DeleteApiKey(c *middleware.Context) Response { id := c.ParamsInt64(":id") cmd := &m.DeleteApiKeyCommand{Id: id, OrgId: c.OrgId} err := bus.Dispatch(cmd) if err != nil { - c.JsonApiErr(500, "Failed to delete API key", err) - return + return ApiError(500, "Failed to delete API key", err) } - c.JsonOK("API key deleted") + return ApiSuccess("API key deleted") } -func AddApiKey(c *middleware.Context, cmd m.AddApiKeyCommand) { +func AddApiKey(c *middleware.Context, cmd m.AddApiKeyCommand) Response { if !cmd.Role.IsValid() { - c.JsonApiErr(400, "Invalid role specified", nil) - return + return ApiError(400, "Invalid role specified", nil) } cmd.OrgId = c.OrgId @@ -53,14 +51,12 @@ func AddApiKey(c *middleware.Context, cmd m.AddApiKeyCommand) { cmd.Key = newKeyInfo.HashedKey if err := bus.Dispatch(&cmd); err != nil { - c.JsonApiErr(500, "Failed to add API key", err) - return + return ApiError(500, "Failed to add API key", err) } result := &dtos.NewApiKeyResult{ Name: cmd.Result.Name, - Key: newKeyInfo.ClientSecret, - } + Key: newKeyInfo.ClientSecret} - c.JSON(200, result) + return Json(200, result) } diff --git a/pkg/api/common.go b/pkg/api/common.go new file mode 100644 index 00000000000..28e95866402 --- /dev/null +++ b/pkg/api/common.go @@ -0,0 +1,122 @@ +package api + +import ( + "encoding/json" + "net/http" + + "github.com/Unknwon/macaron" + "github.com/grafana/grafana/pkg/log" + "github.com/grafana/grafana/pkg/metrics" + "github.com/grafana/grafana/pkg/middleware" + "github.com/grafana/grafana/pkg/setting" +) + +var ( + NotFound = ApiError(404, "Not found", nil) + ServerError = ApiError(500, "Server error", nil) +) + +type Response interface { + WriteTo(out http.ResponseWriter) +} + +type NormalResponse struct { + status int + body []byte + header http.Header +} + +func wrap(action interface{}) macaron.Handler { + + return func(c *middleware.Context) { + var res Response + val, err := c.Invoke(action) + if err == nil && val != nil && len(val) > 0 { + res = val[0].Interface().(Response) + } else { + res = ServerError + } + + res.WriteTo(c.Resp) + } +} + +func (r *NormalResponse) WriteTo(out http.ResponseWriter) { + header := out.Header() + for k, v := range r.header { + header[k] = v + } + out.WriteHeader(r.status) + out.Write(r.body) +} + +func (r *NormalResponse) Cache(ttl string) *NormalResponse { + return r.Header("Cache-Control", "public,max-age="+ttl) +} + +func (r *NormalResponse) Header(key, value string) *NormalResponse { + r.header.Set(key, value) + return r +} + +// functions to create responses + +func Empty(status int) *NormalResponse { + return Respond(status, nil) +} + +func Json(status int, body interface{}) *NormalResponse { + return Respond(status, body).Header("Content-Type", "application/json") +} + +func ApiSuccess(message string) *NormalResponse { + resp := make(map[string]interface{}) + resp["message"] = message + return Respond(200, resp) +} + +func ApiError(status int, message string, err error) *NormalResponse { + resp := make(map[string]interface{}) + + if err != nil { + log.Error(4, "%s: %v", message, err) + if setting.Env != setting.PROD { + resp["error"] = err.Error() + } + } + + switch status { + case 404: + metrics.M_Api_Status_404.Inc(1) + resp["message"] = "Not Found" + case 500: + metrics.M_Api_Status_500.Inc(1) + resp["message"] = "Internal Server Error" + } + + if message != "" { + resp["message"] = message + } + + return Json(status, resp) +} + +func Respond(status int, body interface{}) *NormalResponse { + var b []byte + var err error + switch t := body.(type) { + case []byte: + b = t + case string: + b = []byte(t) + default: + if b, err = json.Marshal(body); err != nil { + return ApiError(500, "body json marshal", err) + } + } + return &NormalResponse{ + body: b, + status: status, + header: make(http.Header), + } +} diff --git a/pkg/api/dashboard.go b/pkg/api/dashboard.go index 278264f22d7..a10c3c92f96 100644 --- a/pkg/api/dashboard.go +++ b/pkg/api/dashboard.go @@ -4,12 +4,14 @@ import ( "encoding/json" "os" "path" + "strings" "github.com/grafana/grafana/pkg/api/dtos" "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/metrics" "github.com/grafana/grafana/pkg/middleware" m "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/search" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/util" ) @@ -30,7 +32,7 @@ func isDasboardStarredByUser(c *middleware.Context, dashId int64) (bool, error) func GetDashboard(c *middleware.Context) { metrics.M_Api_Dashboard_Get.Inc(1) - slug := c.Params(":slug") + slug := strings.ToLower(c.Params(":slug")) query := m.GetDashboardQuery{Slug: slug, OrgId: c.OrgId} err := bus.Dispatch(&query) @@ -46,9 +48,16 @@ func GetDashboard(c *middleware.Context) { } dash := query.Result - dto := dtos.Dashboard{ - Model: dash.Data, - Meta: dtos.DashboardMeta{IsStarred: isStarred, Slug: slug}, + dto := dtos.DashboardFullWithMeta{ + Dashboard: dash.Data, + Meta: dtos.DashboardMeta{ + IsStarred: isStarred, + Slug: slug, + Type: m.DashTypeDB, + CanStar: c.IsSignedIn, + CanSave: c.OrgRole == m.ROLE_ADMIN || c.OrgRole == m.ROLE_EDITOR, + CanEdit: c.OrgRole == m.ROLE_ADMIN || c.OrgRole == m.ROLE_EDITOR || c.OrgRole == m.ROLE_READ_ONLY_EDITOR, + }, } c.JSON(200, dto) @@ -87,6 +96,10 @@ func PostDashboard(c *middleware.Context, cmd m.SaveDashboardCommand) { c.JSON(412, util.DynMap{"status": "version-mismatch", "message": err.Error()}) return } + if err == m.ErrDashboardNotFound { + c.JSON(404, util.DynMap{"status": "not-found", "message": err.Error()}) + return + } c.JsonApiErr(500, "Failed to save dashboard", err) return } @@ -104,13 +117,39 @@ func GetHomeDashboard(c *middleware.Context) { return } - dash := dtos.Dashboard{} + dash := dtos.DashboardFullWithMeta{} dash.Meta.IsHome = true jsonParser := json.NewDecoder(file) - if err := jsonParser.Decode(&dash.Model); err != nil { + if err := jsonParser.Decode(&dash.Dashboard); err != nil { c.JsonApiErr(500, "Failed to load home dashboard", err) return } c.JSON(200, &dash) } + +func GetDashboardFromJsonFile(c *middleware.Context) { + file := c.Params(":file") + + dashboard := search.GetDashboardFromJsonIndex(file) + if dashboard == nil { + c.JsonApiErr(404, "Dashboard not found", nil) + return + } + + dash := dtos.DashboardFullWithMeta{Dashboard: dashboard.Data} + dash.Meta.Type = m.DashTypeJson + + c.JSON(200, &dash) +} + +func GetDashboardTags(c *middleware.Context) { + query := m.GetDashboardTagsQuery{OrgId: c.OrgId} + err := bus.Dispatch(&query) + if err != nil { + c.JsonApiErr(500, "Failed to get tags from database", err) + return + } + + c.JSON(200, query.Result) +} diff --git a/pkg/api/dashboard_snapshot.go b/pkg/api/dashboard_snapshot.go index fe628d580b1..be044cc25eb 100644 --- a/pkg/api/dashboard_snapshot.go +++ b/pkg/api/dashboard_snapshot.go @@ -45,8 +45,8 @@ func CreateDashboardSnapshot(c *middleware.Context, cmd m.CreateDashboardSnapsho } func GetDashboardSnapshot(c *middleware.Context) { - key := c.Params(":key") + key := c.Params(":key") query := &m.GetDashboardSnapshotQuery{Key: key} err := bus.Dispatch(query) @@ -59,13 +59,14 @@ func GetDashboardSnapshot(c *middleware.Context) { // expired snapshots should also be removed from db if snapshot.Expires.Before(time.Now()) { - c.JsonApiErr(404, "Snapshot not found", err) + c.JsonApiErr(404, "Dashboard snapshot not found", err) return } - dto := dtos.Dashboard{ - Model: snapshot.Dashboard, + dto := dtos.DashboardFullWithMeta{ + Dashboard: snapshot.Dashboard, Meta: dtos.DashboardMeta{ + Type: m.DashTypeSnapshot, IsSnapshot: true, Created: snapshot.Created, Expires: snapshot.Expires, diff --git a/pkg/api/dataproxy.go b/pkg/api/dataproxy.go index 81318cdc536..11075294b66 100644 --- a/pkg/api/dataproxy.go +++ b/pkg/api/dataproxy.go @@ -1,9 +1,12 @@ package api import ( + "crypto/tls" + "net" "net/http" "net/http/httputil" "net/url" + "time" "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/middleware" @@ -11,6 +14,16 @@ import ( "github.com/grafana/grafana/pkg/util" ) +var dataProxyTransport = &http.Transport{ + TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, + Proxy: http.ProxyFromEnvironment, + Dial: (&net.Dialer{ + Timeout: 30 * time.Second, + KeepAlive: 30 * time.Second, + }).Dial, + TLSHandshakeTimeout: 10 * time.Second, +} + func NewReverseProxy(ds *m.DataSource, proxyPath string) *httputil.ReverseProxy { target, _ := url.Parse(ds.Url) @@ -56,5 +69,6 @@ func ProxyDataSourceRequest(c *middleware.Context) { proxyPath := c.Params("*") proxy := NewReverseProxy(&query.Result, proxyPath) + proxy.Transport = dataProxyTransport proxy.ServeHTTP(c.RW(), c.Req.Request) } diff --git a/pkg/api/datasources.go b/pkg/api/datasources.go index 76a9bddd253..e0253df3cdb 100644 --- a/pkg/api/datasources.go +++ b/pkg/api/datasources.go @@ -6,6 +6,7 @@ import ( "github.com/grafana/grafana/pkg/middleware" m "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/plugins" + "github.com/grafana/grafana/pkg/util" ) func GetDataSources(c *middleware.Context) { @@ -94,11 +95,12 @@ func AddDataSource(c *middleware.Context, cmd m.AddDataSourceCommand) { return } - c.JsonOK("Datasource added") + c.JSON(200, util.DynMap{"message": "Datasource added", "id": cmd.Result.Id}) } func UpdateDataSource(c *middleware.Context, cmd m.UpdateDataSourceCommand) { cmd.OrgId = c.OrgId + cmd.Id = c.ParamsInt64(":id") err := bus.Dispatch(&cmd) if err != nil { diff --git a/pkg/api/dtos/models.go b/pkg/api/dtos/models.go index 53519631d22..3e1826f56fb 100644 --- a/pkg/api/dtos/models.go +++ b/pkg/api/dtos/models.go @@ -21,24 +21,29 @@ type CurrentUser struct { Email string `json:"email"` Name string `json:"name"` LightTheme bool `json:"lightTheme"` - OrgRole m.RoleType `json:"orgRole"` + OrgId int64 `json:"orgId"` OrgName string `json:"orgName"` + OrgRole m.RoleType `json:"orgRole"` IsGrafanaAdmin bool `json:"isGrafanaAdmin"` GravatarUrl string `json:"gravatarUrl"` } type DashboardMeta struct { - IsStarred bool `json:"isStarred"` - IsHome bool `json:"isHome"` - IsSnapshot bool `json:"isSnapshot"` + IsStarred bool `json:"isStarred,omitempty"` + IsHome bool `json:"isHome,omitempty"` + IsSnapshot bool `json:"isSnapshot,omitempty"` + Type string `json:"type,omitempty"` + CanSave bool `json:"canSave"` + CanEdit bool `json:"canEdit"` + CanStar bool `json:"canStar"` Slug string `json:"slug"` Expires time.Time `json:"expires"` Created time.Time `json:"created"` } -type Dashboard struct { - Meta DashboardMeta `json:"meta"` - Model map[string]interface{} `json:"model"` +type DashboardFullWithMeta struct { + Meta DashboardMeta `json:"meta"` + Dashboard map[string]interface{} `json:"dashboard"` } type DataSource struct { diff --git a/pkg/api/dtos/user.go b/pkg/api/dtos/user.go index 0224ced599d..9b407535429 100644 --- a/pkg/api/dtos/user.go +++ b/pkg/api/dtos/user.go @@ -18,7 +18,7 @@ type AdminUpdateUserPasswordForm struct { } type AdminUpdateUserPermissionsForm struct { - IsGrafanaAdmin bool `json:"IsGrafanaAdmin" binding:"Required"` + IsGrafanaAdmin bool `json:"IsGrafanaAdmin"` } type AdminUserListItem struct { @@ -27,3 +27,13 @@ type AdminUserListItem struct { Login string `json:"login"` IsGrafanaAdmin bool `json:"isGrafanaAdmin"` } + +type SendResetPasswordEmailForm struct { + UserOrEmail string `json:"userOrEmail" binding:"Required"` +} + +type ResetUserPasswordForm struct { + Code string `json:"code"` + NewPassword string `json:"newPassword"` + ConfirmPassword string `json:"confirmPassword"` +} diff --git a/pkg/api/frontendsettings.go b/pkg/api/frontendsettings.go index 3af191a7af7..7851f1d8f0d 100644 --- a/pkg/api/frontendsettings.go +++ b/pkg/api/frontendsettings.go @@ -54,6 +54,10 @@ func getFrontendSettingsMap(c *middleware.Context) (map[string]interface{}, erro defaultDatasource = ds.Name } + if len(ds.JsonData) > 0 { + dsMap["jsonData"] = ds.JsonData + } + if ds.Access == m.DS_ACCESS_DIRECT { if ds.BasicAuth { dsMap["basicAuth"] = util.GetBasicAuthHeader(ds.BasicAuthUser, ds.BasicAuthPassword) @@ -95,6 +99,7 @@ func getFrontendSettingsMap(c *middleware.Context) (map[string]interface{}, erro "defaultDatasource": defaultDatasource, "datasources": datasources, "appSubUrl": setting.AppSubUrl, + "allowOrgCreate": (setting.AllowUserOrgCreate && c.IsSignedIn) || c.IsGrafanaAdmin, "buildInfo": map[string]interface{}{ "version": setting.BuildVersion, "commit": setting.BuildCommit, diff --git a/pkg/api/index.go b/pkg/api/index.go index d9ecf65b699..8f486c4b785 100644 --- a/pkg/api/index.go +++ b/pkg/api/index.go @@ -18,12 +18,17 @@ func setIndexViewData(c *middleware.Context) error { Email: c.Email, Name: c.Name, LightTheme: c.Theme == "light", + OrgId: c.OrgId, OrgName: c.OrgName, OrgRole: c.OrgRole, GravatarUrl: dtos.GetGravatarUrl(c.Email), IsGrafanaAdmin: c.IsGrafanaAdmin, } + if setting.DisableGravatar { + currentUser.GravatarUrl = setting.AppSubUrl + "/img/user_profile.png" + } + if len(currentUser.Name) == 0 { currentUser.Name = currentUser.Login } @@ -54,7 +59,7 @@ func Index(c *middleware.Context) { c.HTML(200, "index") } -func NotFound(c *middleware.Context) { +func NotFoundHandler(c *middleware.Context) { if c.IsApiRequest() { c.JsonApiErr(404, "Not found", nil) return diff --git a/pkg/api/login_oauth.go b/pkg/api/login_oauth.go index 11d62754a18..796599df864 100644 --- a/pkg/api/login_oauth.go +++ b/pkg/api/login_oauth.go @@ -3,6 +3,7 @@ package api import ( "errors" "fmt" + "net/url" "golang.org/x/oauth2" @@ -45,7 +46,13 @@ func OAuthLogin(ctx *middleware.Context) { userInfo, err := connect.UserInfo(token) if err != nil { - ctx.Handle(500, fmt.Sprintf("login.OAuthLogin(get info from %s)", name), err) + if err == social.ErrMissingTeamMembership { + ctx.Redirect(setting.AppSubUrl + "/login?failedMsg=" + url.QueryEscape("Required Github team membership not fulfilled")) + } else if err == social.ErrMissingOrganizationMembership { + ctx.Redirect(setting.AppSubUrl + "/login?failedMsg=" + url.QueryEscape("Required Github organization membership not fulfilled")) + } else { + ctx.Handle(500, fmt.Sprintf("login.OAuthLogin(get info from %s)", name), err) + } return } @@ -54,7 +61,7 @@ func OAuthLogin(ctx *middleware.Context) { // validate that the email is allowed to login to grafana if !connect.IsEmailAllowed(userInfo.Email) { log.Info("OAuth login attempt with unallowed email, %s", userInfo.Email) - ctx.Redirect(setting.AppSubUrl + "/login?email_not_allowed=1") + ctx.Redirect(setting.AppSubUrl + "/login?failedMsg=" + url.QueryEscape("Required email domain not fulfilled")) return } diff --git a/pkg/api/org.go b/pkg/api/org.go index ac8727c9e49..746281c5138 100644 --- a/pkg/api/org.go +++ b/pkg/api/org.go @@ -6,19 +6,28 @@ import ( "github.com/grafana/grafana/pkg/middleware" m "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/setting" + "github.com/grafana/grafana/pkg/util" ) -func GetOrg(c *middleware.Context) { - query := m.GetOrgByIdQuery{Id: c.OrgId} +// GET /api/org +func GetOrgCurrent(c *middleware.Context) Response { + return getOrgHelper(c.OrgId) +} + +// GET /api/orgs/:orgId +func GetOrgById(c *middleware.Context) Response { + return getOrgHelper(c.ParamsInt64(":orgId")) +} + +func getOrgHelper(orgId int64) Response { + query := m.GetOrgByIdQuery{Id: orgId} if err := bus.Dispatch(&query); err != nil { if err == m.ErrOrgNotFound { - c.JsonApiErr(404, "Organization not found", err) - return + return ApiError(404, "Organization not found", err) } - c.JsonApiErr(500, "Failed to get organization", err) - return + return ApiError(500, "Failed to get organization", err) } org := m.OrgDTO{ @@ -26,33 +35,59 @@ func GetOrg(c *middleware.Context) { Name: query.Result.Name, } - c.JSON(200, &org) + return Json(200, &org) } -func CreateOrg(c *middleware.Context, cmd m.CreateOrgCommand) { - if !setting.AllowUserOrgCreate && !c.IsGrafanaAdmin { - c.JsonApiErr(401, "Access denied", nil) - return +// POST /api/orgs +func CreateOrg(c *middleware.Context, cmd m.CreateOrgCommand) Response { + if !c.IsSignedIn || (!setting.AllowUserOrgCreate && !c.IsGrafanaAdmin) { + return ApiError(401, "Access denied", nil) } cmd.UserId = c.UserId if err := bus.Dispatch(&cmd); err != nil { - c.JsonApiErr(500, "Failed to create organization", err) - return + return ApiError(500, "Failed to create organization", err) } metrics.M_Api_Org_Create.Inc(1) - c.JsonOK("Organization created") + return Json(200, &util.DynMap{ + "orgId": cmd.Result.Id, + "message": "Organization created", + }) } -func UpdateOrg(c *middleware.Context, cmd m.UpdateOrgCommand) { +// PUT /api/org +func UpdateOrgCurrent(c *middleware.Context, cmd m.UpdateOrgCommand) Response { cmd.OrgId = c.OrgId + return updateOrgHelper(cmd) +} +// PUT /api/orgs/:orgId +func UpdateOrg(c *middleware.Context, cmd m.UpdateOrgCommand) Response { + cmd.OrgId = c.ParamsInt64(":orgId") + return updateOrgHelper(cmd) +} + +func updateOrgHelper(cmd m.UpdateOrgCommand) Response { if err := bus.Dispatch(&cmd); err != nil { - c.JsonApiErr(500, "Failed to update organization", err) - return + return ApiError(500, "Failed to update organization", err) } - c.JsonOK("Organization updated") + return ApiSuccess("Organization updated") +} + +func SearchOrgs(c *middleware.Context) Response { + query := m.SearchOrgsQuery{ + Query: c.Query("query"), + Name: c.Query("name"), + Page: 0, + Limit: 1000, + } + + if err := bus.Dispatch(&query); err != nil { + return ApiError(500, "Failed to search orgs", err) + } + + return Json(200, query.Result) } diff --git a/pkg/api/org_users.go b/pkg/api/org_users.go index 123ba08c4c7..03570619e6b 100644 --- a/pkg/api/org_users.go +++ b/pkg/api/org_users.go @@ -6,60 +6,115 @@ import ( m "github.com/grafana/grafana/pkg/models" ) -func AddOrgUser(c *middleware.Context, cmd m.AddOrgUserCommand) { +// POST /api/org/users +func AddOrgUserToCurrentOrg(c *middleware.Context, cmd m.AddOrgUserCommand) Response { + cmd.OrgId = c.OrgId + return addOrgUserHelper(cmd) +} + +// POST /api/orgs/:orgId/users +func AddOrgUser(c *middleware.Context, cmd m.AddOrgUserCommand) Response { + cmd.OrgId = c.ParamsInt64(":orgId") + return addOrgUserHelper(cmd) +} + +func addOrgUserHelper(cmd m.AddOrgUserCommand) Response { if !cmd.Role.IsValid() { - c.JsonApiErr(400, "Invalid role specified", nil) - return + return ApiError(400, "Invalid role specified", nil) } userQuery := m.GetUserByLoginQuery{LoginOrEmail: cmd.LoginOrEmail} err := bus.Dispatch(&userQuery) if err != nil { - c.JsonApiErr(404, "User not found", nil) - return + return ApiError(404, "User not found", nil) } userToAdd := userQuery.Result - if userToAdd.Id == c.UserId { - c.JsonApiErr(400, "Cannot add yourself as user", nil) - return - } + // if userToAdd.Id == c.UserId { + // return ApiError(400, "Cannot add yourself as user", nil) + // } - cmd.OrgId = c.OrgId cmd.UserId = userToAdd.Id if err := bus.Dispatch(&cmd); err != nil { - c.JsonApiErr(500, "Could not add user to organization", err) - return + return ApiError(500, "Could not add user to organization", err) } - c.JsonOK("User added to organization") + return ApiSuccess("User added to organization") } -func GetOrgUsers(c *middleware.Context) { - query := m.GetOrgUsersQuery{OrgId: c.OrgId} +// GET /api/org/users +func GetOrgUsersForCurrentOrg(c *middleware.Context) Response { + return getOrgUsersHelper(c.OrgId) +} + +// GET /api/orgs/:orgId/users +func GetOrgUsers(c *middleware.Context) Response { + return getOrgUsersHelper(c.ParamsInt64(":orgId")) +} + +func getOrgUsersHelper(orgId int64) Response { + query := m.GetOrgUsersQuery{OrgId: orgId} if err := bus.Dispatch(&query); err != nil { - c.JsonApiErr(500, "Failed to get account user", err) - return + return ApiError(500, "Failed to get account user", err) } - c.JSON(200, query.Result) + return Json(200, query.Result) } -func RemoveOrgUser(c *middleware.Context) { - userId := c.ParamsInt64(":id") +// PATCH /api/org/users/:userId +func UpdateOrgUserForCurrentOrg(c *middleware.Context, cmd m.UpdateOrgUserCommand) Response { + cmd.OrgId = c.OrgId + cmd.UserId = c.ParamsInt64(":userId") + return updateOrgUserHelper(cmd) +} - cmd := m.RemoveOrgUserCommand{OrgId: c.OrgId, UserId: userId} +// PATCH /api/orgs/:orgId/users/:userId +func UpdateOrgUser(c *middleware.Context, cmd m.UpdateOrgUserCommand) Response { + cmd.OrgId = c.ParamsInt64(":orgId") + cmd.UserId = c.ParamsInt64(":userId") + return updateOrgUserHelper(cmd) +} + +func updateOrgUserHelper(cmd m.UpdateOrgUserCommand) Response { + if !cmd.Role.IsValid() { + return ApiError(400, "Invalid role specified", nil) + } if err := bus.Dispatch(&cmd); err != nil { if err == m.ErrLastOrgAdmin { - c.JsonApiErr(400, "Cannot remove last organization admin", nil) - return + return ApiError(400, "Cannot change role so that there is no organization admin left", nil) } - c.JsonApiErr(500, "Failed to remove user from organization", err) + return ApiError(500, "Failed update org user", err) } - c.JsonOK("User removed from organization") + return ApiSuccess("Organization user updated") +} + +// DELETE /api/org/users/:userId +func RemoveOrgUserForCurrentOrg(c *middleware.Context) Response { + userId := c.ParamsInt64(":userId") + return removeOrgUserHelper(c.OrgId, userId) +} + +// DELETE /api/orgs/:orgId/users/:userId +func RemoveOrgUser(c *middleware.Context) Response { + userId := c.ParamsInt64(":userId") + orgId := c.ParamsInt64(":orgId") + return removeOrgUserHelper(orgId, userId) +} + +func removeOrgUserHelper(orgId int64, userId int64) Response { + cmd := m.RemoveOrgUserCommand{OrgId: orgId, UserId: userId} + + if err := bus.Dispatch(&cmd); err != nil { + if err == m.ErrLastOrgAdmin { + return ApiError(400, "Cannot remove last organization admin", nil) + } + return ApiError(500, "Failed to remove user from organization", err) + } + + return ApiSuccess("User removed from organization") } diff --git a/pkg/api/password.go b/pkg/api/password.go new file mode 100644 index 00000000000..f3c2b0b7058 --- /dev/null +++ b/pkg/api/password.go @@ -0,0 +1,49 @@ +package api + +import ( + "github.com/grafana/grafana/pkg/api/dtos" + "github.com/grafana/grafana/pkg/bus" + "github.com/grafana/grafana/pkg/middleware" + m "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/util" +) + +func SendResetPasswordEmail(c *middleware.Context, form dtos.SendResetPasswordEmailForm) Response { + userQuery := m.GetUserByLoginQuery{LoginOrEmail: form.UserOrEmail} + + if err := bus.Dispatch(&userQuery); err != nil { + return ApiError(404, "User does not exist", err) + } + + emailCmd := m.SendResetPasswordEmailCommand{User: userQuery.Result} + if err := bus.Dispatch(&emailCmd); err != nil { + return ApiError(500, "Failed to send email", err) + } + + return ApiSuccess("Email sent") +} + +func ResetPassword(c *middleware.Context, form dtos.ResetUserPasswordForm) Response { + query := m.ValidateResetPasswordCodeQuery{Code: form.Code} + + if err := bus.Dispatch(&query); err != nil { + if err == m.ErrInvalidEmailCode { + return ApiError(400, "Invalid or expired reset password code", nil) + } + return ApiError(500, "Unknown error validating email code", err) + } + + if form.NewPassword != form.ConfirmPassword { + return ApiError(400, "Passwords do not match", nil) + } + + cmd := m.ChangeUserPasswordCommand{} + cmd.UserId = query.Result.Id + cmd.NewPassword = util.EncodePassword(form.NewPassword, query.Result.Salt) + + if err := bus.Dispatch(&cmd); err != nil { + return ApiError(500, "Failed to change user password", err) + } + + return ApiSuccess("User password changed") +} diff --git a/pkg/api/search.go b/pkg/api/search.go index c37bba7b669..035e59fa3f4 100644 --- a/pkg/api/search.go +++ b/pkg/api/search.go @@ -3,79 +3,33 @@ package api import ( "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/middleware" - m "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/search" ) -// TODO: this needs to be cached or improved somehow -func setIsStarredFlagOnSearchResults(c *middleware.Context, hits []*m.DashboardSearchHit) error { - if !c.IsSignedIn { - return nil - } - - query := m.GetUserStarsQuery{UserId: c.UserId} - if err := bus.Dispatch(&query); err != nil { - return err - } - - for _, dash := range hits { - if _, exists := query.Result[dash.Id]; exists { - dash.IsStarred = true - } - } - - return nil -} - func Search(c *middleware.Context) { query := c.Query("query") - tag := c.Query("tag") - tagcloud := c.Query("tagcloud") + tags := c.QueryStrings("tag") starred := c.Query("starred") limit := c.QueryInt("limit") if limit == 0 { - limit = 200 + limit = 1000 } - result := m.SearchResult{ - Dashboards: []*m.DashboardSearchHit{}, - Tags: []*m.DashboardTagCloudItem{}, + searchQuery := search.Query{ + Title: query, + Tags: tags, + UserId: c.UserId, + Limit: limit, + IsStarred: starred == "true", + OrgId: c.OrgId, } - if tagcloud == "true" { - - query := m.GetDashboardTagsQuery{OrgId: c.OrgId} - err := bus.Dispatch(&query) - if err != nil { - c.JsonApiErr(500, "Failed to get tags from database", err) - return - } - result.Tags = query.Result - result.TagsOnly = true - - } else { - query := m.SearchDashboardsQuery{ - Title: query, - Tag: tag, - UserId: c.UserId, - Limit: limit, - IsStarred: starred == "true", - OrgId: c.OrgId, - } - - err := bus.Dispatch(&query) - if err != nil { - c.JsonApiErr(500, "Search failed", err) - return - } - - if err := setIsStarredFlagOnSearchResults(c, query.Result); err != nil { - c.JsonApiErr(500, "Failed to get user stars", err) - return - } - - result.Dashboards = query.Result + err := bus.Dispatch(&searchQuery) + if err != nil { + c.JsonApiErr(500, "Search failed", err) + return } - c.JSON(200, result) + c.JSON(200, searchQuery.Result) } diff --git a/pkg/api/signup.go b/pkg/api/signup.go index 63bb34c72ac..77305caba70 100644 --- a/pkg/api/signup.go +++ b/pkg/api/signup.go @@ -2,6 +2,7 @@ package api import ( "github.com/grafana/grafana/pkg/bus" + "github.com/grafana/grafana/pkg/events" "github.com/grafana/grafana/pkg/metrics" "github.com/grafana/grafana/pkg/middleware" m "github.com/grafana/grafana/pkg/models" @@ -9,24 +10,29 @@ import ( ) // POST /api/user/signup -func SignUp(c *middleware.Context, cmd m.CreateUserCommand) { +func SignUp(c *middleware.Context, cmd m.CreateUserCommand) Response { if !setting.AllowUserSignUp { - c.JsonApiErr(401, "User signup is disabled", nil) - return + return ApiError(401, "User signup is disabled", nil) } cmd.Login = cmd.Email if err := bus.Dispatch(&cmd); err != nil { - c.JsonApiErr(500, "failed to create user", err) - return + return ApiError(500, "failed to create user", err) } user := cmd.Result + bus.Publish(&events.UserSignedUp{ + Id: user.Id, + Name: user.Name, + Email: user.Email, + Login: user.Login, + }) + loginUserWithUser(&user, c) - c.JsonOK("User created and logged in") - metrics.M_Api_User_SignUp.Inc(1) + + return ApiSuccess("User created and logged in") } diff --git a/pkg/api/stars.go b/pkg/api/stars.go index 2b5e64ce8e0..c6f9d037eba 100644 --- a/pkg/api/stars.go +++ b/pkg/api/stars.go @@ -6,40 +6,35 @@ import ( m "github.com/grafana/grafana/pkg/models" ) -func StarDashboard(c *middleware.Context) { - var cmd = m.StarDashboardCommand{ - UserId: c.UserId, - DashboardId: c.ParamsInt64(":id"), +func StarDashboard(c *middleware.Context) Response { + if !c.IsSignedIn { + return ApiError(412, "You need to sign in to star dashboards", nil) } + cmd := m.StarDashboardCommand{UserId: c.UserId, DashboardId: c.ParamsInt64(":id")} + if cmd.DashboardId <= 0 { - c.JsonApiErr(400, "Missing dashboard id", nil) - return + return ApiError(400, "Missing dashboard id", nil) } if err := bus.Dispatch(&cmd); err != nil { - c.JsonApiErr(500, "Failed to star dashboard", err) - return + return ApiError(500, "Failed to star dashboard", err) } - c.JsonOK("Dashboard starred!") + return ApiSuccess("Dashboard starred!") } -func UnstarDashboard(c *middleware.Context) { - var cmd = m.UnstarDashboardCommand{ - UserId: c.UserId, - DashboardId: c.ParamsInt64(":id"), - } +func UnstarDashboard(c *middleware.Context) Response { + + cmd := m.UnstarDashboardCommand{UserId: c.UserId, DashboardId: c.ParamsInt64(":id")} if cmd.DashboardId <= 0 { - c.JsonApiErr(400, "Missing dashboard id", nil) - return + return ApiError(400, "Missing dashboard id", nil) } if err := bus.Dispatch(&cmd); err != nil { - c.JsonApiErr(500, "Failed to unstar dashboard", err) - return + return ApiError(500, "Failed to unstar dashboard", err) } - c.JsonOK("Dashboard unstarred") + return ApiSuccess("Dashboard unstarred") } diff --git a/pkg/api/user.go b/pkg/api/user.go index 9d870a10a1b..5af243eeb22 100644 --- a/pkg/api/user.go +++ b/pkg/api/user.go @@ -7,44 +7,71 @@ import ( "github.com/grafana/grafana/pkg/util" ) -func GetUser(c *middleware.Context) { - query := m.GetUserProfileQuery{UserId: c.UserId} - - if err := bus.Dispatch(&query); err != nil { - c.JsonApiErr(500, "Failed to get user", err) - return - } - - c.JSON(200, query.Result) +// GET /api/user (current authenticated user) +func GetSignedInUser(c *middleware.Context) Response { + return getUserUserProfile(c.UserId) } -func UpdateUser(c *middleware.Context, cmd m.UpdateUserCommand) { +// GET /api/user/:id +func GetUserById(c *middleware.Context) Response { + return getUserUserProfile(c.ParamsInt64(":id")) +} + +func getUserUserProfile(userId int64) Response { + query := m.GetUserProfileQuery{UserId: userId} + + if err := bus.Dispatch(&query); err != nil { + return ApiError(500, "Failed to get user", err) + } + + return Json(200, query.Result) +} + +// POST /api/user +func UpdateSignedInUser(c *middleware.Context, cmd m.UpdateUserCommand) Response { cmd.UserId = c.UserId - - if err := bus.Dispatch(&cmd); err != nil { - c.JsonApiErr(400, "Failed to update user", err) - return - } - - c.JsonOK("User updated") + return handleUpdateUser(cmd) } -func GetUserOrgList(c *middleware.Context) { - query := m.GetUserOrgListQuery{UserId: c.UserId} +// POST /api/users/:id +func UpdateUser(c *middleware.Context, cmd m.UpdateUserCommand) Response { + cmd.UserId = c.ParamsInt64(":id") + return handleUpdateUser(cmd) +} - if err := bus.Dispatch(&query); err != nil { - c.JsonApiErr(500, "Failed to get user organizations", err) - return - } - - for _, ac := range query.Result { - if ac.OrgId == c.OrgId { - ac.IsUsing = true - break +func handleUpdateUser(cmd m.UpdateUserCommand) Response { + if len(cmd.Login) == 0 { + cmd.Login = cmd.Email + if len(cmd.Login) == 0 { + return ApiError(400, "Validation error, need specify either username or email", nil) } } - c.JSON(200, query.Result) + if err := bus.Dispatch(&cmd); err != nil { + return ApiError(500, "failed to update user", err) + } + + return ApiSuccess("User updated") +} + +// GET /api/user/orgs +func GetSignedInUserOrgList(c *middleware.Context) Response { + return getUserOrgList(c.UserId) +} + +// GET /api/user/:id/orgs +func GetUserOrgList(c *middleware.Context) Response { + return getUserOrgList(c.ParamsInt64(":id")) +} + +func getUserOrgList(userId int64) Response { + query := m.GetUserOrgListQuery{UserId: userId} + + if err := bus.Dispatch(&query); err != nil { + return ApiError(500, "Faile to get user organziations", err) + } + + return Json(200, query.Result) } func validateUsingOrg(userId int64, orgId int64) bool { @@ -65,53 +92,55 @@ func validateUsingOrg(userId int64, orgId int64) bool { return valid } -func UserSetUsingOrg(c *middleware.Context) { +// POST /api/user/using/:id +func UserSetUsingOrg(c *middleware.Context) Response { orgId := c.ParamsInt64(":id") if !validateUsingOrg(c.UserId, orgId) { - c.JsonApiErr(401, "Not a valid organization", nil) - return + return ApiError(401, "Not a valid organization", nil) } - cmd := m.SetUsingOrgCommand{ - UserId: c.UserId, - OrgId: orgId, - } + cmd := m.SetUsingOrgCommand{UserId: c.UserId, OrgId: orgId} if err := bus.Dispatch(&cmd); err != nil { - c.JsonApiErr(500, "Failed change active organization", err) - return + return ApiError(500, "Failed change active organization", err) } - c.JsonOK("Active organization changed") + return ApiSuccess("Active organization changed") } -func ChangeUserPassword(c *middleware.Context, cmd m.ChangeUserPasswordCommand) { +func ChangeUserPassword(c *middleware.Context, cmd m.ChangeUserPasswordCommand) Response { userQuery := m.GetUserByIdQuery{Id: c.UserId} if err := bus.Dispatch(&userQuery); err != nil { - c.JsonApiErr(500, "Could not read user from database", err) - return + return ApiError(500, "Could not read user from database", err) } passwordHashed := util.EncodePassword(cmd.OldPassword, userQuery.Result.Salt) if passwordHashed != userQuery.Result.Password { - c.JsonApiErr(401, "Invalid old password", nil) - return + return ApiError(401, "Invalid old password", nil) } if len(cmd.NewPassword) < 4 { - c.JsonApiErr(400, "New password too short", nil) - return + return ApiError(400, "New password too short", nil) } cmd.UserId = c.UserId cmd.NewPassword = util.EncodePassword(cmd.NewPassword, userQuery.Result.Salt) if err := bus.Dispatch(&cmd); err != nil { - c.JsonApiErr(500, "Failed to change user password", err) - return + return ApiError(500, "Failed to change user password", err) } - c.JsonOK("User password changed") + return ApiSuccess("User password changed") +} + +// GET /api/users +func SearchUsers(c *middleware.Context) Response { + query := m.SearchUsersQuery{Query: "", Page: 0, Limit: 1000} + if err := bus.Dispatch(&query); err != nil { + return ApiError(500, "Failed to fetch users", err) + } + + return Json(200, query.Result) } diff --git a/pkg/bus/bus.go b/pkg/bus/bus.go index 2865c740fd1..6eb4b741a27 100644 --- a/pkg/bus/bus.go +++ b/pkg/bus/bus.go @@ -1,7 +1,7 @@ package bus import ( - "errors" + "fmt" "reflect" ) @@ -39,7 +39,7 @@ func (b *InProcBus) Dispatch(msg Msg) error { var handler = b.handlers[msgName] if handler == nil { - return errors.New("handler not found") + return fmt.Errorf("handler not found for %s", msgName) } var params = make([]reflect.Value, 1) @@ -121,3 +121,7 @@ func Dispatch(msg Msg) error { func Publish(msg Msg) error { return globalBus.Publish(msg) } + +func ClearBusHandlers() { + globalBus = New() +} diff --git a/pkg/cmd/web.go b/pkg/cmd/web.go index 7b6014aeb7e..c94661a5f9a 100644 --- a/pkg/cmd/web.go +++ b/pkg/cmd/web.go @@ -33,6 +33,7 @@ func newMacaron() *macaron.Macaron { mapStatic(m, "css", "css") mapStatic(m, "img", "img") mapStatic(m, "fonts", "fonts") + mapStatic(m, "robots.txt", "robots.txxt") m.Use(macaron.Renderer(macaron.RenderOptions{ Directory: path.Join(setting.StaticRootPath, "views"), @@ -40,8 +41,12 @@ func newMacaron() *macaron.Macaron { Delims: macaron.Delims{Left: "[[", Right: "]]"}, })) + if setting.EnforceDomain { + m.Use(middleware.ValidateHostHeader(setting.Domain)) + } + m.Use(middleware.GetContextHandler()) - m.Use(middleware.Sessioner(setting.SessionOptions)) + m.Use(middleware.Sessioner(&setting.SessionOptions)) return m } diff --git a/pkg/components/renderer/renderer.go b/pkg/components/renderer/renderer.go index aa9e0c92525..ec11a9ac36f 100644 --- a/pkg/components/renderer/renderer.go +++ b/pkg/components/renderer/renderer.go @@ -26,7 +26,7 @@ func RenderToPng(params *RenderOpts) (string, error) { pngPath, _ := filepath.Abs(filepath.Join(setting.ImagesDir, util.GetRandomString(20))) pngPath = pngPath + ".png" - cmd := exec.Command(binPath, "--ignore-ssl-errors=true", scriptPath, "url="+params.Url, "width="+params.Width, + cmd := exec.Command(binPath, "--ignore-ssl-errors=true", "--ssl-protocol=any", scriptPath, "url="+params.Url, "width="+params.Width, "height="+params.Height, "png="+pngPath, "cookiename="+setting.SessionOptions.CookieName, "domain="+setting.Domain, "sessionid="+params.SessionId) stdout, err := cmd.StdoutPipe() @@ -54,7 +54,7 @@ func RenderToPng(params *RenderOpts) (string, error) { }() select { - case <-time.After(10 * time.Second): + case <-time.After(15 * time.Second): if err := cmd.Process.Kill(); err != nil { log.Error(4, "failed to kill: %v", err) } diff --git a/pkg/events/events.go b/pkg/events/events.go index c3dcac3e2b5..5e82578b474 100644 --- a/pkg/events/events.go +++ b/pkg/events/events.go @@ -5,7 +5,7 @@ import ( "time" ) -// Events can be passed to external systems via for example AMPQ +// Events can be passed to external systems via for example AMQP // Treat these events as basically DTOs so changes has to be backward compatible type Priority string @@ -70,6 +70,14 @@ type UserCreated struct { Email string `json:"email"` } +type UserSignedUp struct { + Timestamp time.Time `json:"timestamp"` + Id int64 `json:"id"` + Name string `json:"name"` + Login string `json:"login"` + Email string `json:"email"` +} + type UserUpdated struct { Timestamp time.Time `json:"timestamp"` Id int64 `json:"id"` diff --git a/pkg/middleware/auth_proxy.go b/pkg/middleware/auth_proxy.go new file mode 100644 index 00000000000..2529fef67a9 --- /dev/null +++ b/pkg/middleware/auth_proxy.go @@ -0,0 +1,71 @@ +package middleware + +import ( + "github.com/grafana/grafana/pkg/bus" + m "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/setting" +) + +func initContextWithAuthProxy(ctx *Context) bool { + if !setting.AuthProxyEnabled { + return false + } + + proxyHeaderValue := ctx.Req.Header.Get(setting.AuthProxyHeaderName) + if len(proxyHeaderValue) == 0 { + return false + } + + query := getSignedInUserQueryForProxyAuth(proxyHeaderValue) + if err := bus.Dispatch(query); err != nil { + if err != m.ErrUserNotFound { + ctx.Handle(500, "Failed find user specifed in auth proxy header", err) + return true + } + + if setting.AuthProxyAutoSignUp { + cmd := getCreateUserCommandForProxyAuth(proxyHeaderValue) + if err := bus.Dispatch(cmd); err != nil { + ctx.Handle(500, "Failed to create user specified in auth proxy header", err) + return true + } + query = &m.GetSignedInUserQuery{UserId: cmd.Result.Id} + if err := bus.Dispatch(query); err != nil { + ctx.Handle(500, "Failed find user after creation", err) + return true + } + } else { + return false + } + } + + ctx.SignedInUser = query.Result + ctx.IsSignedIn = true + return true +} + +func getSignedInUserQueryForProxyAuth(headerVal string) *m.GetSignedInUserQuery { + query := m.GetSignedInUserQuery{} + if setting.AuthProxyHeaderProperty == "username" { + query.Login = headerVal + } else if setting.AuthProxyHeaderProperty == "email" { + query.Email = headerVal + } else { + panic("Auth proxy header property invalid") + } + return &query +} + +func getCreateUserCommandForProxyAuth(headerVal string) *m.CreateUserCommand { + cmd := m.CreateUserCommand{} + if setting.AuthProxyHeaderProperty == "username" { + cmd.Login = headerVal + cmd.Email = headerVal + } else if setting.AuthProxyHeaderProperty == "email" { + cmd.Email = headerVal + cmd.Login = headerVal + } else { + panic("Auth proxy header property invalid") + } + return &cmd +} diff --git a/pkg/middleware/auth_test.go b/pkg/middleware/auth_test.go new file mode 100644 index 00000000000..81b0f525e98 --- /dev/null +++ b/pkg/middleware/auth_test.go @@ -0,0 +1,35 @@ +package middleware + +import ( + "testing" + + . "github.com/smartystreets/goconvey/convey" +) + +func TestMiddlewareAuth(t *testing.T) { + + Convey("Given the grafana middleware", t, func() { + reqSignIn := Auth(&AuthOptions{ReqSignedIn: true}) + + middlewareScenario("ReqSignIn true and unauthenticated request", func(sc *scenarioContext) { + sc.m.Get("/secure", reqSignIn, sc.defaultHandler) + + sc.fakeReq("GET", "/secure").exec() + + Convey("Should redirect to login", func() { + So(sc.resp.Code, ShouldEqual, 302) + }) + }) + + middlewareScenario("ReqSignIn true and unauthenticated API request", func(sc *scenarioContext) { + sc.m.Get("/api/secure", reqSignIn, sc.defaultHandler) + + sc.fakeReq("GET", "/api/secure").exec() + + Convey("Should return 401", func() { + So(sc.resp.Code, ShouldEqual, 401) + }) + }) + + }) +} diff --git a/pkg/middleware/middleware.go b/pkg/middleware/middleware.go index b93cd517364..8704ec5a787 100644 --- a/pkg/middleware/middleware.go +++ b/pkg/middleware/middleware.go @@ -12,6 +12,7 @@ import ( "github.com/grafana/grafana/pkg/metrics" m "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/setting" + "github.com/grafana/grafana/pkg/util" ) type Context struct { @@ -40,6 +41,8 @@ func GetContextHandler() macaron.Handler { // then look for api key in session (special case for render calls via api) // then test if anonymous access is enabled if initContextWithApiKey(ctx) || + initContextWithBasicAuth(ctx) || + initContextWithAuthProxy(ctx) || initContextWithUserSessionCookie(ctx) || initContextWithApiKeyFromSession(ctx) || initContextWithAnonymousUser(ctx) { @@ -83,6 +86,7 @@ func initContextWithUserSessionCookie(ctx *Context) bool { query := m.GetSignedInUserQuery{UserId: userId} if err := bus.Dispatch(&query); err != nil { + log.Error(3, "Failed to get user with id %v", userId) return false } else { ctx.SignedInUser = query.Result @@ -126,6 +130,47 @@ func initContextWithApiKey(ctx *Context) bool { } } +func initContextWithBasicAuth(ctx *Context) bool { + if !setting.BasicAuthEnabled { + return false + } + + header := ctx.Req.Header.Get("Authorization") + if header == "" { + return false + } + + username, password, err := util.DecodeBasicAuthHeader(header) + if err != nil { + ctx.JsonApiErr(401, "Invalid Basic Auth Header", err) + return true + } + + loginQuery := m.GetUserByLoginQuery{LoginOrEmail: username} + if err := bus.Dispatch(&loginQuery); err != nil { + ctx.JsonApiErr(401, "Basic auth failed", err) + return true + } + + user := loginQuery.Result + + // validate password + if util.EncodePassword(password, user.Salt) != user.Password { + ctx.JsonApiErr(401, "Invalid username or password", nil) + return true + } + + query := m.GetSignedInUserQuery{UserId: user.Id} + if err := bus.Dispatch(&query); err != nil { + ctx.JsonApiErr(401, "Authentication error", err) + return true + } else { + ctx.SignedInUser = query.Result + ctx.IsSignedIn = true + return true + } +} + // special case for panel render calls with api key func initContextWithApiKeyFromSession(ctx *Context) bool { keyId := ctx.Session.Get(SESS_KEY_APIKEY) @@ -195,10 +240,10 @@ func (ctx *Context) JsonApiErr(status int, message string, err error) { switch status { case 404: - resp["message"] = "Not Found" - metrics.M_Api_Status_500.Inc(1) - case 500: metrics.M_Api_Status_404.Inc(1) + resp["message"] = "Not Found" + case 500: + metrics.M_Api_Status_500.Inc(1) resp["message"] = "Internal Server Error" } diff --git a/pkg/middleware/middleware_test.go b/pkg/middleware/middleware_test.go new file mode 100644 index 00000000000..97d369d00cf --- /dev/null +++ b/pkg/middleware/middleware_test.go @@ -0,0 +1,314 @@ +package middleware + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "path/filepath" + "testing" + + "github.com/Unknwon/macaron" + "github.com/grafana/grafana/pkg/bus" + m "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/setting" + "github.com/grafana/grafana/pkg/util" + "github.com/macaron-contrib/session" + . "github.com/smartystreets/goconvey/convey" +) + +func TestMiddlewareContext(t *testing.T) { + + Convey("Given the grafana middleware", t, func() { + middlewareScenario("middleware should add context to injector", func(sc *scenarioContext) { + sc.fakeReq("GET", "/").exec() + So(sc.context, ShouldNotBeNil) + }) + + middlewareScenario("Default middleware should allow get request", func(sc *scenarioContext) { + sc.fakeReq("GET", "/").exec() + So(sc.resp.Code, ShouldEqual, 200) + }) + + middlewareScenario("Non api request should init session", func(sc *scenarioContext) { + sc.fakeReq("GET", "/").exec() + So(sc.resp.Header().Get("Set-Cookie"), ShouldContainSubstring, "grafana_sess") + }) + + middlewareScenario("Invalid api key", func(sc *scenarioContext) { + sc.apiKey = "invalid_key_test" + sc.fakeReq("GET", "/").exec() + + Convey("Should not init session", func() { + So(sc.resp.Header().Get("Set-Cookie"), ShouldBeEmpty) + }) + + Convey("Should return 401", func() { + So(sc.resp.Code, ShouldEqual, 401) + So(sc.respJson["message"], ShouldEqual, "Invalid API key") + }) + }) + + middlewareScenario("Using basic auth", func(sc *scenarioContext) { + + bus.AddHandler("test", func(query *m.GetUserByLoginQuery) error { + query.Result = &m.User{ + Password: util.EncodePassword("myPass", "salt"), + Salt: "salt", + } + return nil + }) + + bus.AddHandler("test", func(query *m.GetSignedInUserQuery) error { + query.Result = &m.SignedInUser{OrgId: 2, UserId: 12} + return nil + }) + + setting.BasicAuthEnabled = true + authHeader := util.GetBasicAuthHeader("myUser", "myPass") + sc.fakeReq("GET", "/").withAuthoriziationHeader(authHeader).exec() + + Convey("Should init middleware context with user", func() { + So(sc.context.IsSignedIn, ShouldEqual, true) + So(sc.context.OrgId, ShouldEqual, 2) + So(sc.context.UserId, ShouldEqual, 12) + }) + }) + + middlewareScenario("Valid api key", func(sc *scenarioContext) { + keyhash := util.EncodePassword("v5nAwpMafFP6znaS4urhdWDLS5511M42", "asd") + + bus.AddHandler("test", func(query *m.GetApiKeyByNameQuery) error { + query.Result = &m.ApiKey{OrgId: 12, Role: m.ROLE_EDITOR, Key: keyhash} + return nil + }) + + sc.fakeReq("GET", "/").withValidApiKey().exec() + + Convey("Should return 200", func() { + So(sc.resp.Code, ShouldEqual, 200) + }) + + Convey("Should init middleware context", func() { + So(sc.context.IsSignedIn, ShouldEqual, true) + So(sc.context.OrgId, ShouldEqual, 12) + So(sc.context.OrgRole, ShouldEqual, m.ROLE_EDITOR) + }) + }) + + middlewareScenario("Valid api key, but does not match db hash", func(sc *scenarioContext) { + keyhash := "something_not_matching" + + bus.AddHandler("test", func(query *m.GetApiKeyByNameQuery) error { + query.Result = &m.ApiKey{OrgId: 12, Role: m.ROLE_EDITOR, Key: keyhash} + return nil + }) + + sc.fakeReq("GET", "/").withValidApiKey().exec() + + Convey("Should return api key invalid", func() { + So(sc.resp.Code, ShouldEqual, 401) + So(sc.respJson["message"], ShouldEqual, "Invalid API key") + }) + }) + + middlewareScenario("UserId in session", func(sc *scenarioContext) { + + sc.fakeReq("GET", "/").handler(func(c *Context) { + c.Session.Set(SESS_KEY_USERID, int64(12)) + }).exec() + + bus.AddHandler("test", func(query *m.GetSignedInUserQuery) error { + query.Result = &m.SignedInUser{OrgId: 2, UserId: 12} + return nil + }) + + sc.fakeReq("GET", "/").exec() + + Convey("should init context with user info", func() { + So(sc.context.IsSignedIn, ShouldBeTrue) + So(sc.context.UserId, ShouldEqual, 12) + }) + }) + + middlewareScenario("When anonymous access is enabled", func(sc *scenarioContext) { + setting.AnonymousEnabled = true + setting.AnonymousOrgName = "test" + setting.AnonymousOrgRole = string(m.ROLE_EDITOR) + + bus.AddHandler("test", func(query *m.GetOrgByNameQuery) error { + So(query.Name, ShouldEqual, "test") + + query.Result = &m.Org{Id: 2, Name: "test"} + return nil + }) + + sc.fakeReq("GET", "/").exec() + + Convey("should init context with org info", func() { + So(sc.context.UserId, ShouldEqual, 0) + So(sc.context.OrgId, ShouldEqual, 2) + So(sc.context.OrgRole, ShouldEqual, m.ROLE_EDITOR) + }) + + Convey("context signed in should be false", func() { + So(sc.context.IsSignedIn, ShouldBeFalse) + }) + }) + + middlewareScenario("When auth_proxy is enabled enabled and user exists", func(sc *scenarioContext) { + setting.AuthProxyEnabled = true + setting.AuthProxyHeaderName = "X-WEBAUTH-USER" + setting.AuthProxyHeaderProperty = "username" + + bus.AddHandler("test", func(query *m.GetSignedInUserQuery) error { + query.Result = &m.SignedInUser{OrgId: 2, UserId: 12} + return nil + }) + + sc.fakeReq("GET", "/") + sc.req.Header.Add("X-WEBAUTH-USER", "torkelo") + sc.exec() + + Convey("should init context with user info", func() { + So(sc.context.IsSignedIn, ShouldBeTrue) + So(sc.context.UserId, ShouldEqual, 12) + So(sc.context.OrgId, ShouldEqual, 2) + }) + }) + + middlewareScenario("When auth_proxy is enabled enabled and user does not exists", func(sc *scenarioContext) { + setting.AuthProxyEnabled = true + setting.AuthProxyHeaderName = "X-WEBAUTH-USER" + setting.AuthProxyHeaderProperty = "username" + setting.AuthProxyAutoSignUp = true + + bus.AddHandler("test", func(query *m.GetSignedInUserQuery) error { + if query.UserId > 0 { + query.Result = &m.SignedInUser{OrgId: 4, UserId: 33} + return nil + } else { + return m.ErrUserNotFound + } + }) + + var createUserCmd *m.CreateUserCommand + bus.AddHandler("test", func(cmd *m.CreateUserCommand) error { + createUserCmd = cmd + cmd.Result = m.User{Id: 33} + return nil + }) + + sc.fakeReq("GET", "/") + sc.req.Header.Add("X-WEBAUTH-USER", "torkelo") + sc.exec() + + Convey("Should create user if auto sign up is enabled", func() { + So(sc.context.IsSignedIn, ShouldBeTrue) + So(sc.context.UserId, ShouldEqual, 33) + So(sc.context.OrgId, ShouldEqual, 4) + + }) + }) + + }) +} + +func middlewareScenario(desc string, fn scenarioFunc) { + Convey(desc, func() { + defer bus.ClearBusHandlers() + + sc := &scenarioContext{} + viewsPath, _ := filepath.Abs("../../public/views") + + sc.m = macaron.New() + sc.m.Use(macaron.Renderer(macaron.RenderOptions{ + Directory: viewsPath, + Delims: macaron.Delims{Left: "[[", Right: "]]"}, + })) + + sc.m.Use(GetContextHandler()) + // mock out gc goroutine + startSessionGC = func() {} + sc.m.Use(Sessioner(&session.Options{})) + + sc.defaultHandler = func(c *Context) { + sc.context = c + if sc.handlerFunc != nil { + sc.handlerFunc(sc.context) + } + } + + sc.m.Get("/", sc.defaultHandler) + + fn(sc) + }) +} + +type scenarioContext struct { + m *macaron.Macaron + context *Context + resp *httptest.ResponseRecorder + apiKey string + authHeader string + respJson map[string]interface{} + handlerFunc handlerFunc + defaultHandler macaron.Handler + + req *http.Request +} + +func (sc *scenarioContext) withValidApiKey() *scenarioContext { + sc.apiKey = "eyJrIjoidjVuQXdwTWFmRlA2em5hUzR1cmhkV0RMUzU1MTFNNDIiLCJuIjoiYXNkIiwiaWQiOjF9" + return sc +} + +func (sc *scenarioContext) withInvalidApiKey() *scenarioContext { + sc.apiKey = "nvalidhhhhds" + return sc +} + +func (sc *scenarioContext) withAuthoriziationHeader(authHeader string) *scenarioContext { + sc.authHeader = authHeader + return sc +} + +func (sc *scenarioContext) fakeReq(method, url string) *scenarioContext { + sc.resp = httptest.NewRecorder() + req, err := http.NewRequest(method, url, nil) + So(err, ShouldBeNil) + sc.req = req + + // add session cookie from last request + if sc.context != nil { + if sc.context.Session.ID() != "" { + req.Header.Add("Cookie", "grafana_sess="+sc.context.Session.ID()+";") + } + } + + return sc +} + +func (sc *scenarioContext) handler(fn handlerFunc) *scenarioContext { + sc.handlerFunc = fn + return sc +} + +func (sc *scenarioContext) exec() { + if sc.apiKey != "" { + sc.req.Header.Add("Authorization", "Bearer "+sc.apiKey) + } + + if sc.authHeader != "" { + sc.req.Header.Add("Authorization", sc.authHeader) + } + + sc.m.ServeHTTP(sc.resp, sc.req) + + if sc.resp.Header().Get("Content-Type") == "application/json; charset=UTF-8" { + err := json.NewDecoder(sc.resp.Body).Decode(&sc.respJson) + So(err, ShouldBeNil) + } +} + +type scenarioFunc func(c *scenarioContext) +type handlerFunc func(c *Context) diff --git a/pkg/middleware/session.go b/pkg/middleware/session.go index 71f87b343ff..7b036b9790e 100644 --- a/pkg/middleware/session.go +++ b/pkg/middleware/session.go @@ -16,17 +16,46 @@ const ( ) var sessionManager *session.Manager -var sessionOptions session.Options +var sessionOptions *session.Options +var startSessionGC func() -func startSessionGC() { - sessionManager.GC() - time.AfterFunc(time.Duration(sessionOptions.Gclifetime)*time.Second, startSessionGC) +func init() { + startSessionGC = func() { + sessionManager.GC() + time.AfterFunc(time.Duration(sessionOptions.Gclifetime)*time.Second, startSessionGC) + } } -func Sessioner(options session.Options) macaron.Handler { +func prepareOptions(opt *session.Options) *session.Options { + if len(opt.Provider) == 0 { + opt.Provider = "memory" + } + if len(opt.ProviderConfig) == 0 { + opt.ProviderConfig = "data/sessions" + } + if len(opt.CookieName) == 0 { + opt.CookieName = "grafana_sess" + } + if len(opt.CookiePath) == 0 { + opt.CookiePath = "/" + } + if opt.Gclifetime == 0 { + opt.Gclifetime = 3600 + } + if opt.Maxlifetime == 0 { + opt.Maxlifetime = opt.Gclifetime + } + if opt.IDLength == 0 { + opt.IDLength = 16 + } + + return opt +} + +func Sessioner(options *session.Options) macaron.Handler { var err error - sessionOptions = options - sessionManager, err = session.NewManager(options.Provider, options) + sessionOptions = prepareOptions(options) + sessionManager, err = session.NewManager(options.Provider, *options) if err != nil { panic(err) } diff --git a/pkg/middleware/validate_host.go b/pkg/middleware/validate_host.go new file mode 100644 index 00000000000..56e0a8ee35a --- /dev/null +++ b/pkg/middleware/validate_host.go @@ -0,0 +1,22 @@ +package middleware + +import ( + "strings" + + "github.com/Unknwon/macaron" + "github.com/grafana/grafana/pkg/setting" +) + +func ValidateHostHeader(domain string) macaron.Handler { + return func(c *macaron.Context) { + h := c.Req.Host + if i := strings.Index(h, ":"); i >= 0 { + h = h[:i] + } + + if !strings.EqualFold(h, domain) { + c.Redirect(strings.TrimSuffix(setting.AppUrl, "/")+c.Req.RequestURI, 301) + return + } + } +} diff --git a/pkg/models/dashboards.go b/pkg/models/dashboards.go index 7c4e8db1611..7d4a2690556 100644 --- a/pkg/models/dashboards.go +++ b/pkg/models/dashboards.go @@ -10,11 +10,19 @@ import ( // Typed errors var ( - ErrDashboardNotFound = errors.New("Account not found") + ErrDashboardNotFound = errors.New("Dashboard not found") + ErrDashboardSnapshotNotFound = errors.New("Dashboard snapshot not found") ErrDashboardWithSameNameExists = errors.New("A dashboard with the same name already exists") ErrDashboardVersionMismatch = errors.New("The dashboard has been changed by someone else") ) +var ( + DashTypeJson = "file" + DashTypeDB = "db" + DashTypeScript = "script" + DashTypeSnapshot = "snapshot" +) + // Dashboard model type Dashboard struct { Id int64 @@ -42,7 +50,7 @@ func NewDashboard(title string) *Dashboard { // GetTags turns the tags in data json into go string array func (dash *Dashboard) GetTags() []string { jsonTags := dash.Data["tags"] - if jsonTags == nil { + if jsonTags == nil || jsonTags == "" { return []string{} } @@ -54,12 +62,10 @@ func (dash *Dashboard) GetTags() []string { return b } -// GetDashboardModel turns the command into the savable model -func (cmd *SaveDashboardCommand) GetDashboardModel() *Dashboard { +func NewDashboardFromJson(data map[string]interface{}) *Dashboard { dash := &Dashboard{} - dash.Data = cmd.Dashboard + dash.Data = data dash.Title = dash.Data["title"].(string) - dash.OrgId = cmd.OrgId dash.UpdateSlug() if dash.Data["id"] != nil { @@ -75,6 +81,14 @@ func (cmd *SaveDashboardCommand) GetDashboardModel() *Dashboard { return dash } +// GetDashboardModel turns the command into the savable model +func (cmd *SaveDashboardCommand) GetDashboardModel() *Dashboard { + dash := NewDashboardFromJson(cmd.Dashboard) + dash.OrgId = cmd.OrgId + dash.UpdateSlug() + return dash +} + // GetString a func (dash *Dashboard) GetString(prop string) string { return dash.Data[prop].(string) @@ -113,3 +127,13 @@ type GetDashboardQuery struct { Result *Dashboard } + +type DashboardTagCloudItem struct { + Term string `json:"term"` + Count int `json:"count"` +} + +type GetDashboardTagsQuery struct { + OrgId int64 + Result []*DashboardTagCloudItem +} diff --git a/pkg/models/dashboard_test.go b/pkg/models/dashboards_test.go similarity index 53% rename from pkg/models/dashboard_test.go rename to pkg/models/dashboards_test.go index 0828e51480f..b0b6796c4d8 100644 --- a/pkg/models/dashboard_test.go +++ b/pkg/models/dashboards_test.go @@ -15,4 +15,17 @@ func TestDashboardModel(t *testing.T) { So(dashboard.Slug, ShouldEqual, "grafana-play-home") }) + Convey("Given a dashboard json", t, func() { + json := map[string]interface{}{ + "title": "test dash", + } + + Convey("With tags as string value", func() { + json["tags"] = "" + dash := NewDashboardFromJson(json) + + So(len(dash.GetTags()), ShouldEqual, 0) + }) + }) + } diff --git a/pkg/models/datasource.go b/pkg/models/datasource.go index 2ba236cd56b..c756faaba59 100644 --- a/pkg/models/datasource.go +++ b/pkg/models/datasource.go @@ -69,7 +69,6 @@ type AddDataSourceCommand struct { // Also acts as api DTO type UpdateDataSourceCommand struct { - Id int64 `json:"id" binding:"Required"` Name string `json:"name" binding:"Required"` Type string `json:"type" binding:"Required"` Access DsAccess `json:"access" binding:"Required"` @@ -84,6 +83,7 @@ type UpdateDataSourceCommand struct { JsonData map[string]interface{} `json:"jsonData"` OrgId int64 `json:"-"` + Id int64 `json:"-"` } type DeleteDataSourceCommand struct { diff --git a/pkg/models/emails.go b/pkg/models/emails.go new file mode 100644 index 00000000000..74da180f7d8 --- /dev/null +++ b/pkg/models/emails.go @@ -0,0 +1,22 @@ +package models + +import "errors" + +var ErrInvalidEmailCode = errors.New("Invalid or expired email code") + +type SendEmailCommand struct { + To []string + Template string + Data map[string]interface{} + Massive bool + Info string +} + +type SendResetPasswordEmailCommand struct { + User *User +} + +type ValidateResetPasswordCodeQuery struct { + Code string + Result *User +} diff --git a/pkg/models/models.go b/pkg/models/models.go index c38f0c5a391..189e594576b 100644 --- a/pkg/models/models.go +++ b/pkg/models/models.go @@ -1,7 +1,5 @@ package models -import "errors" - type OAuthType int const ( @@ -9,5 +7,3 @@ const ( GOOGLE TWITTER ) - -var ErrNotFound = errors.New("Not found") diff --git a/pkg/models/org.go b/pkg/models/org.go index ab6d97b9ae8..b2d18be9537 100644 --- a/pkg/models/org.go +++ b/pkg/models/org.go @@ -48,8 +48,13 @@ type GetOrgByNameQuery struct { Result *Org } -type GetOrgListQuery struct { - Result []*Org +type SearchOrgsQuery struct { + Query string + Name string + Limit int + Page int + + Result []*OrgDTO } type OrgDTO struct { @@ -58,8 +63,7 @@ type OrgDTO struct { } type UserOrgDTO struct { - OrgId int64 `json:"orgId"` - Name string `json:"name"` - Role RoleType `json:"role"` - IsUsing bool `json:"isUsing"` + OrgId int64 `json:"orgId"` + Name string `json:"name"` + Role RoleType `json:"role"` } diff --git a/pkg/models/org_user.go b/pkg/models/org_user.go index 811d02e1afe..afbb10386c8 100644 --- a/pkg/models/org_user.go +++ b/pkg/models/org_user.go @@ -9,21 +9,24 @@ import ( var ( ErrInvalidRoleType = errors.New("Invalid role type") ErrLastOrgAdmin = errors.New("Cannot remove last organization admin") + ErrOrgUserNotFound = errors.New("Cannot find the organization user") ) type RoleType string const ( - ROLE_VIEWER RoleType = "Viewer" - ROLE_EDITOR RoleType = "Editor" - ROLE_ADMIN RoleType = "Admin" + ROLE_VIEWER RoleType = "Viewer" + ROLE_EDITOR RoleType = "Editor" + ROLE_READ_ONLY_EDITOR RoleType = "Read Only Editor" + ROLE_ADMIN RoleType = "Admin" ) func (r RoleType) IsValid() bool { - return r == ROLE_VIEWER || r == ROLE_ADMIN || r == ROLE_EDITOR + return r == ROLE_VIEWER || r == ROLE_ADMIN || r == ROLE_EDITOR || r == ROLE_READ_ONLY_EDITOR } type OrgUser struct { + Id int64 OrgId int64 UserId int64 Role RoleType @@ -47,6 +50,13 @@ type AddOrgUserCommand struct { UserId int64 `json:"-"` } +type UpdateOrgUserCommand struct { + Role RoleType `json:"role" binding:"Required"` + + OrgId int64 `json:"-"` + UserId int64 `json:"-"` +} + // ---------------------- // QUERIES diff --git a/pkg/models/search.go b/pkg/models/search.go index e5c69f0038e..8bd4744f07b 100644 --- a/pkg/models/search.go +++ b/pkg/models/search.go @@ -1,36 +1,10 @@ package models -type SearchResult struct { - Dashboards []*DashboardSearchHit `json:"dashboards"` - Tags []*DashboardTagCloudItem `json:"tags"` - TagsOnly bool `json:"tagsOnly"` -} - -type DashboardSearchHit struct { +type SearchHit struct { Id int64 `json:"id"` Title string `json:"title"` - Slug string `json:"slug"` + Uri string `json:"uri"` + Type string `json:"type"` Tags []string `json:"tags"` IsStarred bool `json:"isStarred"` } - -type DashboardTagCloudItem struct { - Term string `json:"term"` - Count int `json:"count"` -} - -type SearchDashboardsQuery struct { - Title string - Tag string - OrgId int64 - UserId int64 - Limit int - IsStarred bool - - Result []*DashboardSearchHit -} - -type GetDashboardTagsQuery struct { - OrgId int64 - Result []*DashboardTagCloudItem -} diff --git a/pkg/models/user.go b/pkg/models/user.go index cdc688d4c1f..bf697676b32 100644 --- a/pkg/models/user.go +++ b/pkg/models/user.go @@ -30,6 +30,16 @@ type User struct { Updated time.Time } +func (u *User) NameOrFallback() string { + if u.Name != "" { + return u.Name + } else if u.Login != "" { + return u.Login + } else { + return u.Email + } +} + // --------------------- // COMMANDS @@ -89,6 +99,8 @@ type GetUserByIdQuery struct { type GetSignedInUserQuery struct { UserId int64 + Login string + Email string Result *SignedInUser } @@ -131,6 +143,7 @@ type UserProfileDTO struct { Name string `json:"name"` Login string `json:"login"` Theme string `json:"theme"` + OrgId int64 `json:"orgId"` IsGrafanaAdmin bool `json:"isGrafanaAdmin"` } diff --git a/pkg/services/eventpublisher/eventpublisher.go b/pkg/services/eventpublisher/eventpublisher.go index 14e527b2cc7..2854b63a9a5 100644 --- a/pkg/services/eventpublisher/eventpublisher.go +++ b/pkg/services/eventpublisher/eventpublisher.go @@ -109,25 +109,26 @@ func Setup() error { } func publish(routingKey string, msgString []byte) { - err := channel.Publish( - exchange, //exchange - routingKey, // routing key - false, // mandatory - false, // immediate - amqp.Publishing{ - ContentType: "application/json", - Body: msgString, - }, - ) - if err != nil { + for { + err := channel.Publish( + exchange, //exchange + routingKey, // routing key + false, // mandatory + false, // immediate + amqp.Publishing{ + ContentType: "application/json", + Body: msgString, + }, + ) + if err == nil { + return + } // failures are most likely because the connection was lost. // the connection will be re-established, so just keep // retrying every 2seconds until we successfully publish. time.Sleep(2 * time.Second) fmt.Println("publish failed, retrying.") - publish(routingKey, msgString) } - return } func eventListener(event interface{}) error { diff --git a/pkg/services/notifications/codes.go b/pkg/services/notifications/codes.go new file mode 100644 index 00000000000..4dbe76c1cad --- /dev/null +++ b/pkg/services/notifications/codes.go @@ -0,0 +1,98 @@ +package notifications + +import ( + "crypto/sha1" + "encoding/hex" + "fmt" + "time" + + "github.com/Unknwon/com" + m "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/setting" +) + +const timeLimitCodeLength = 12 + 6 + 40 + +// create a time limit code +// code format: 12 length date time string + 6 minutes string + 40 sha1 encoded string +func createTimeLimitCode(data string, minutes int, startInf interface{}) string { + format := "200601021504" + + var start, end time.Time + var startStr, endStr string + + if startInf == nil { + // Use now time create code + start = time.Now() + startStr = start.Format(format) + } else { + // use start string create code + startStr = startInf.(string) + start, _ = time.ParseInLocation(format, startStr, time.Local) + startStr = start.Format(format) + } + + end = start.Add(time.Minute * time.Duration(minutes)) + endStr = end.Format(format) + + // create sha1 encode string + sh := sha1.New() + sh.Write([]byte(data + setting.SecretKey + startStr + endStr + com.ToStr(minutes))) + encoded := hex.EncodeToString(sh.Sum(nil)) + + code := fmt.Sprintf("%s%06d%s", startStr, minutes, encoded) + return code +} + +// verify time limit code +func validateUserEmailCode(user *m.User, code string) bool { + if len(code) <= 18 { + return false + } + + minutes := setting.EmailCodeValidMinutes + code = code[:timeLimitCodeLength] + + // split code + start := code[:12] + lives := code[12:18] + if d, err := com.StrTo(lives).Int(); err == nil { + minutes = d + } + + // right active code + data := com.ToStr(user.Id) + user.Email + user.Login + user.Password + user.Rands + retCode := createTimeLimitCode(data, minutes, start) + fmt.Printf("code : %s\ncode2: %s", retCode, code) + if retCode == code && minutes > 0 { + // check time is expired or not + before, _ := time.ParseInLocation("200601021504", start, time.Local) + now := time.Now() + if before.Add(time.Minute*time.Duration(minutes)).Unix() > now.Unix() { + return true + } + } + + return false +} + +func getLoginForEmailCode(code string) string { + if len(code) <= timeLimitCodeLength { + return "" + } + + // use tail hex username query user + hexStr := code[timeLimitCodeLength:] + b, _ := hex.DecodeString(hexStr) + return string(b) +} + +func createUserEmailCode(u *m.User, startInf interface{}) string { + minutes := setting.EmailCodeValidMinutes + data := com.ToStr(u.Id) + u.Email + u.Login + u.Password + u.Rands + code := createTimeLimitCode(data, minutes, startInf) + + // add tail hex username + code += hex.EncodeToString([]byte(u.Login)) + return code +} diff --git a/pkg/services/notifications/codes_test.go b/pkg/services/notifications/codes_test.go new file mode 100644 index 00000000000..be1fc91153b --- /dev/null +++ b/pkg/services/notifications/codes_test.go @@ -0,0 +1,35 @@ +package notifications + +import ( + "testing" + + m "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/setting" + . "github.com/smartystreets/goconvey/convey" +) + +func TestEmailCodes(t *testing.T) { + + Convey("When generating code", t, func() { + setting.EmailCodeValidMinutes = 120 + + user := &m.User{Id: 10, Email: "t@a.com", Login: "asd", Password: "1", Rands: "2"} + code := createUserEmailCode(user, nil) + + Convey("getLoginForCode should return login", func() { + login := getLoginForEmailCode(code) + So(login, ShouldEqual, "asd") + }) + + Convey("Can verify valid code", func() { + So(validateUserEmailCode(user, code), ShouldBeTrue) + }) + + Convey("Cannot verify in-valid code", func() { + code = "ASD" + So(validateUserEmailCode(user, code), ShouldBeFalse) + }) + + }) + +} diff --git a/pkg/services/notifications/email.go b/pkg/services/notifications/email.go new file mode 100644 index 00000000000..f81f3e1007b --- /dev/null +++ b/pkg/services/notifications/email.go @@ -0,0 +1,33 @@ +package notifications + +import ( + m "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/setting" +) + +type Message struct { + To []string + From string + Subject string + Body string + Massive bool + Info string +} + +// create mail content +func (m *Message) Content() string { + contentType := "text/html; charset=UTF-8" + content := "From: " + m.From + "\r\nSubject: " + m.Subject + "\r\nContent-Type: " + contentType + "\r\n\r\n" + m.Body + return content +} + +func setDefaultTemplateData(data map[string]interface{}, u *m.User) { + data["AppUrl"] = setting.AppUrl + data["BuildVersion"] = setting.BuildVersion + data["BuildStamp"] = setting.BuildStamp + data["EmailCodeValidHours"] = setting.EmailCodeValidMinutes / 60 + data["Subject"] = map[string]interface{}{} + if u != nil { + data["Name"] = u.NameOrFallback() + } +} diff --git a/pkg/services/notifications/mailer.go b/pkg/services/notifications/mailer.go new file mode 100644 index 00000000000..309436cb7d9 --- /dev/null +++ b/pkg/services/notifications/mailer.go @@ -0,0 +1,186 @@ +// Copyright 2014 The Gogs Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package notifications + +import ( + "crypto/tls" + "fmt" + "net" + "net/mail" + "net/smtp" + "os" + "strings" + + "github.com/grafana/grafana/pkg/log" + "github.com/grafana/grafana/pkg/setting" +) + +var mailQueue chan *Message + +func initMailQueue() { + mailQueue = make(chan *Message, 10) + go processMailQueue() +} + +func processMailQueue() { + for { + select { + case msg := <-mailQueue: + num, err := buildAndSend(msg) + tos := strings.Join(msg.To, "; ") + info := "" + if err != nil { + if len(msg.Info) > 0 { + info = ", info: " + msg.Info + } + log.Error(4, fmt.Sprintf("Async sent email %d succeed, not send emails: %s%s err: %s", num, tos, info, err)) + } else { + log.Trace(fmt.Sprintf("Async sent email %d succeed, sent emails: %s%s", num, tos, info)) + } + } + } +} + +var addToMailQueue = func(msg *Message) { + mailQueue <- msg +} + +func sendToSmtpServer(recipients []string, msgContent []byte) error { + host, port, err := net.SplitHostPort(setting.Smtp.Host) + if err != nil { + return err + } + + tlsconfig := &tls.Config{ + InsecureSkipVerify: setting.Smtp.SkipVerify, + ServerName: host, + } + + if setting.Smtp.CertFile != "" { + cert, err := tls.LoadX509KeyPair(setting.Smtp.CertFile, setting.Smtp.KeyFile) + if err != nil { + return err + } + tlsconfig.Certificates = []tls.Certificate{cert} + } + + conn, err := net.Dial("tcp", net.JoinHostPort(host, port)) + if err != nil { + return err + } + defer conn.Close() + + isSecureConn := false + // Start TLS directly if the port ends with 465 (SMTPS protocol) + if strings.HasSuffix(port, "465") { + conn = tls.Client(conn, tlsconfig) + isSecureConn = true + } + + client, err := smtp.NewClient(conn, host) + if err != nil { + return err + } + + hostname, err := os.Hostname() + if err != nil { + return err + } + + if err = client.Hello(hostname); err != nil { + return err + } + + // If not using SMTPS, alway use STARTTLS if available + hasStartTLS, _ := client.Extension("STARTTLS") + if !isSecureConn && hasStartTLS { + if err = client.StartTLS(tlsconfig); err != nil { + return err + } + } + + canAuth, options := client.Extension("AUTH") + + if canAuth && len(setting.Smtp.User) > 0 { + var auth smtp.Auth + + if strings.Contains(options, "CRAM-MD5") { + auth = smtp.CRAMMD5Auth(setting.Smtp.User, setting.Smtp.Password) + } else if strings.Contains(options, "PLAIN") { + auth = smtp.PlainAuth("", setting.Smtp.User, setting.Smtp.Password, host) + } + + if auth != nil { + if err = client.Auth(auth); err != nil { + return err + } + } + } + + if fromAddress, err := mail.ParseAddress(setting.Smtp.FromAddress); err != nil { + return err + } else { + if err = client.Mail(fromAddress.Address); err != nil { + return err + } + } + + for _, rec := range recipients { + if err = client.Rcpt(rec); err != nil { + return err + } + } + + w, err := client.Data() + if err != nil { + return err + } + if _, err = w.Write([]byte(msgContent)); err != nil { + return err + } + + if err = w.Close(); err != nil { + return err + } + + return client.Quit() +} + +func buildAndSend(msg *Message) (int, error) { + log.Trace("Sending mails to: %s", strings.Join(msg.To, "; ")) + + // get message body + content := msg.Content() + + if len(msg.To) == 0 { + return 0, fmt.Errorf("empty receive emails") + } else if len(msg.Body) == 0 { + return 0, fmt.Errorf("empty email body") + } + + if msg.Massive { + // send mail to multiple emails one by one + num := 0 + for _, to := range msg.To { + body := []byte("To: " + to + "\r\n" + content) + err := sendToSmtpServer([]string{to}, body) + if err != nil { + return num, err + } + num++ + } + return num, nil + } else { + body := []byte("To: " + strings.Join(msg.To, ";") + "\r\n" + content) + + // send to multiple emails in one message + err := sendToSmtpServer(msg.To, body) + if err != nil { + return 0, err + } else { + return 1, nil + } + } +} diff --git a/pkg/services/notifications/notifications.go b/pkg/services/notifications/notifications.go new file mode 100644 index 00000000000..401cd812d5e --- /dev/null +++ b/pkg/services/notifications/notifications.go @@ -0,0 +1,137 @@ +package notifications + +import ( + "bytes" + "errors" + "html/template" + "path/filepath" + + "github.com/grafana/grafana/pkg/bus" + "github.com/grafana/grafana/pkg/events" + "github.com/grafana/grafana/pkg/log" + m "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/setting" + "github.com/grafana/grafana/pkg/util" +) + +var mailTemplates *template.Template +var tmplResetPassword = "reset_password.html" +var tmplWelcomeOnSignUp = "welcome_on_signup.html" + +func Init() error { + initMailQueue() + + bus.AddHandler("email", sendResetPasswordEmail) + bus.AddHandler("email", validateResetPasswordCode) + bus.AddHandler("email", sendEmailCommandHandler) + + bus.AddEventListener(userSignedUpHandler) + + mailTemplates = template.New("name") + mailTemplates.Funcs(template.FuncMap{ + "Subject": subjectTemplateFunc, + }) + + templatePattern := filepath.Join(setting.StaticRootPath, setting.Smtp.TemplatesPattern) + _, err := mailTemplates.ParseGlob(templatePattern) + if err != nil { + return err + } + + if !util.IsEmail(setting.Smtp.FromAddress) { + return errors.New("Invalid email address for smpt from_adress config") + } + + if setting.EmailCodeValidMinutes == 0 { + setting.EmailCodeValidMinutes = 120 + } + + return nil +} + +func subjectTemplateFunc(obj map[string]interface{}, value string) string { + obj["value"] = value + return "" +} + +func sendEmailCommandHandler(cmd *m.SendEmailCommand) error { + if !setting.Smtp.Enabled { + return errors.New("Grafana mailing/smtp options not configured, contact your Grafana admin") + } + + var buffer bytes.Buffer + data := cmd.Data + if data == nil { + data = make(map[string]interface{}, 10) + } + + setDefaultTemplateData(data, nil) + mailTemplates.ExecuteTemplate(&buffer, cmd.Template, data) + + subjectTmplText := data["Subject"].(map[string]interface{})["value"].(string) + subjectTmpl, err := template.New("subject").Parse(subjectTmplText) + if err != nil { + return err + } + + var subjectBuffer bytes.Buffer + err = subjectTmpl.ExecuteTemplate(&subjectBuffer, "subject", data) + if err != nil { + return err + } + + addToMailQueue(&Message{ + To: cmd.To, + From: setting.Smtp.FromAddress, + Subject: subjectBuffer.String(), + Body: buffer.String(), + }) + + return nil +} + +func sendResetPasswordEmail(cmd *m.SendResetPasswordEmailCommand) error { + return sendEmailCommandHandler(&m.SendEmailCommand{ + To: []string{cmd.User.Email}, + Template: tmplResetPassword, + Data: map[string]interface{}{ + "Code": createUserEmailCode(cmd.User, nil), + "Name": cmd.User.NameOrFallback(), + }, + }) +} + +func validateResetPasswordCode(query *m.ValidateResetPasswordCodeQuery) error { + login := getLoginForEmailCode(query.Code) + if login == "" { + return m.ErrInvalidEmailCode + } + + userQuery := m.GetUserByLoginQuery{LoginOrEmail: login} + if err := bus.Dispatch(&userQuery); err != nil { + return err + } + + if !validateUserEmailCode(userQuery.Result, query.Code) { + return m.ErrInvalidEmailCode + } + + query.Result = userQuery.Result + return nil +} + +func userSignedUpHandler(evt *events.UserSignedUp) error { + log.Info("User signed up: %s, send_option: %s", evt.Email, setting.Smtp.SendWelcomeEmailOnSignUp) + + if evt.Email == "" || !setting.Smtp.SendWelcomeEmailOnSignUp { + return nil + } + + return sendEmailCommandHandler(&m.SendEmailCommand{ + To: []string{evt.Email}, + Template: tmplWelcomeOnSignUp, + Data: map[string]interface{}{ + "Name": evt.Login, + }, + }) +} diff --git a/pkg/services/notifications/notifications_test.go b/pkg/services/notifications/notifications_test.go new file mode 100644 index 00000000000..110e24cb810 --- /dev/null +++ b/pkg/services/notifications/notifications_test.go @@ -0,0 +1,39 @@ +package notifications + +import ( + "testing" + + "github.com/grafana/grafana/pkg/bus" + m "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/setting" + . "github.com/smartystreets/goconvey/convey" +) + +func TestNotifications(t *testing.T) { + + Convey("Given the notifications service", t, func() { + bus.ClearBusHandlers() + + setting.StaticRootPath = "../../../public/" + setting.Smtp.Enabled = true + setting.Smtp.TemplatesPattern = "emails/*.html" + setting.Smtp.FromAddress = "from@address.com" + + err := Init() + So(err, ShouldBeNil) + + var sentMsg *Message + addToMailQueue = func(msg *Message) { + sentMsg = msg + } + + Convey("When sending reset email password", func() { + err := sendResetPasswordEmail(&m.SendResetPasswordEmailCommand{User: &m.User{Email: "asd@asd.com"}}) + So(err, ShouldBeNil) + So(sentMsg.Body, ShouldContainSubstring, "body") + So(sentMsg.Subject, ShouldEqual, "Reset your Grafana password - asd@asd.com") + So(sentMsg.Body, ShouldNotContainSubstring, "Subject") + }) + }) + +} diff --git a/pkg/services/search/handlers.go b/pkg/services/search/handlers.go new file mode 100644 index 00000000000..1c480992cbc --- /dev/null +++ b/pkg/services/search/handlers.go @@ -0,0 +1,134 @@ +package search + +import ( + "log" + "path/filepath" + "sort" + + "github.com/grafana/grafana/pkg/bus" + m "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/setting" +) + +var jsonDashIndex *JsonDashIndex + +func Init() { + bus.AddHandler("search", searchHandler) + + jsonIndexCfg, _ := setting.Cfg.GetSection("dashboards.json") + + if jsonIndexCfg == nil { + log.Fatal("Config section missing: dashboards.json") + return + } + + jsonIndexEnabled := jsonIndexCfg.Key("enabled").MustBool(false) + + if jsonIndexEnabled { + jsonFilesPath := jsonIndexCfg.Key("path").String() + if !filepath.IsAbs(jsonFilesPath) { + jsonFilesPath = filepath.Join(setting.HomePath, jsonFilesPath) + } + + jsonDashIndex = NewJsonDashIndex(jsonFilesPath) + go jsonDashIndex.updateLoop() + } +} + +func searchHandler(query *Query) error { + hits := make(HitList, 0) + + dashQuery := FindPersistedDashboardsQuery{ + Title: query.Title, + UserId: query.UserId, + IsStarred: query.IsStarred, + OrgId: query.OrgId, + } + + if err := bus.Dispatch(&dashQuery); err != nil { + return err + } + + hits = append(hits, dashQuery.Result...) + + if jsonDashIndex != nil { + jsonHits, err := jsonDashIndex.Search(query) + if err != nil { + return err + } + + hits = append(hits, jsonHits...) + } + + // filter out results with tag filter + if len(query.Tags) > 0 { + filtered := HitList{} + for _, hit := range hits { + if hasRequiredTags(query.Tags, hit.Tags) { + filtered = append(filtered, hit) + } + } + hits = filtered + } + + // sort main result array + sort.Sort(hits) + + if len(hits) > query.Limit { + hits = hits[0:query.Limit] + } + + // sort tags + for _, hit := range hits { + sort.Strings(hit.Tags) + } + + // add isStarred info + if err := setIsStarredFlagOnSearchResults(query.UserId, hits); err != nil { + return err + } + + query.Result = hits + return nil +} + +func stringInSlice(a string, list []string) bool { + for _, b := range list { + if b == a { + return true + } + } + return false +} + +func hasRequiredTags(queryTags, hitTags []string) bool { + for _, queryTag := range queryTags { + if !stringInSlice(queryTag, hitTags) { + return false + } + } + + return true +} + +func setIsStarredFlagOnSearchResults(userId int64, hits []*Hit) error { + query := m.GetUserStarsQuery{UserId: userId} + if err := bus.Dispatch(&query); err != nil { + return err + } + + for _, dash := range hits { + if _, exists := query.Result[dash.Id]; exists { + dash.IsStarred = true + } + } + + return nil +} + +func GetDashboardFromJsonIndex(filename string) *m.Dashboard { + if jsonDashIndex == nil { + return nil + } + return jsonDashIndex.GetDashboard(filename) +} diff --git a/pkg/services/search/handlers_test.go b/pkg/services/search/handlers_test.go new file mode 100644 index 00000000000..bb355ec146f --- /dev/null +++ b/pkg/services/search/handlers_test.go @@ -0,0 +1,61 @@ +package search + +import ( + "testing" + + "github.com/grafana/grafana/pkg/bus" + m "github.com/grafana/grafana/pkg/models" + . "github.com/smartystreets/goconvey/convey" +) + +func TestSearch(t *testing.T) { + + Convey("Given search query", t, func() { + jsonDashIndex = NewJsonDashIndex("../../../public/dashboards/") + query := Query{Limit: 2000} + + bus.AddHandler("test", func(query *FindPersistedDashboardsQuery) error { + query.Result = HitList{ + &Hit{Id: 16, Title: "CCAA", Tags: []string{"BB", "AA"}}, + &Hit{Id: 10, Title: "AABB", Tags: []string{"CC", "AA"}}, + &Hit{Id: 15, Title: "BBAA", Tags: []string{"EE", "AA", "BB"}}, + } + return nil + }) + + bus.AddHandler("test", func(query *m.GetUserStarsQuery) error { + query.Result = map[int64]bool{10: true, 12: true} + return nil + }) + + Convey("That is empty", func() { + err := searchHandler(&query) + So(err, ShouldBeNil) + + Convey("should return sorted results", func() { + So(query.Result[0].Title, ShouldEqual, "AABB") + So(query.Result[1].Title, ShouldEqual, "BBAA") + So(query.Result[2].Title, ShouldEqual, "CCAA") + }) + + Convey("should return sorted tags", func() { + So(query.Result[1].Tags[0], ShouldEqual, "AA") + So(query.Result[1].Tags[1], ShouldEqual, "BB") + So(query.Result[1].Tags[2], ShouldEqual, "EE") + }) + }) + + Convey("That filters by tag", func() { + query.Tags = []string{"BB", "AA"} + err := searchHandler(&query) + So(err, ShouldBeNil) + + Convey("should return correct results", func() { + So(len(query.Result), ShouldEqual, 2) + So(query.Result[0].Title, ShouldEqual, "BBAA") + So(query.Result[1].Title, ShouldEqual, "CCAA") + }) + + }) + }) +} diff --git a/pkg/services/search/json_index.go b/pkg/services/search/json_index.go new file mode 100644 index 00000000000..e70c662438d --- /dev/null +++ b/pkg/services/search/json_index.go @@ -0,0 +1,139 @@ +package search + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "time" + + "github.com/grafana/grafana/pkg/log" + m "github.com/grafana/grafana/pkg/models" +) + +type JsonDashIndex struct { + path string + items []*JsonDashIndexItem +} + +type JsonDashIndexItem struct { + TitleLower string + TagsCsv string + Path string + Dashboard *m.Dashboard +} + +func NewJsonDashIndex(path string) *JsonDashIndex { + log.Info("Creating json dashboard index for path: %v", path) + + index := JsonDashIndex{} + index.path = path + index.updateIndex() + return &index +} + +func (index *JsonDashIndex) updateLoop() { + ticker := time.NewTicker(time.Minute) + for { + select { + case <-ticker.C: + if err := index.updateIndex(); err != nil { + log.Error(3, "Failed to update dashboard json index %v", err) + } + } + } +} + +func (index *JsonDashIndex) Search(query *Query) ([]*Hit, error) { + results := make([]*Hit, 0) + + if query.IsStarred { + return results, nil + } + + queryStr := strings.ToLower(query.Title) + + for _, item := range index.items { + if len(results) > query.Limit { + break + } + + // add results with matchig title filter + if strings.Contains(item.TitleLower, queryStr) { + results = append(results, &Hit{ + Type: DashHitJson, + Title: item.Dashboard.Title, + Tags: item.Dashboard.GetTags(), + Uri: "file/" + item.Path, + }) + } + } + + return results, nil +} + +func (index *JsonDashIndex) GetDashboard(path string) *m.Dashboard { + for _, item := range index.items { + if item.Path == path { + return item.Dashboard + } + } + + return nil +} + +func (index *JsonDashIndex) updateIndex() error { + var items = make([]*JsonDashIndexItem, 0) + + visitor := func(path string, f os.FileInfo, err error) error { + if err != nil { + return err + } + if f.IsDir() { + return nil + } + + if strings.HasSuffix(f.Name(), ".json") { + dash, err := loadDashboardFromFile(path) + if err != nil { + return err + } + + items = append(items, dash) + } + + return nil + } + + if err := filepath.Walk(index.path, visitor); err != nil { + return err + } + + index.items = items + return nil +} + +func loadDashboardFromFile(filename string) (*JsonDashIndexItem, error) { + reader, err := os.Open(filename) + if err != nil { + return nil, err + } + defer reader.Close() + + jsonParser := json.NewDecoder(reader) + var data map[string]interface{} + + if err := jsonParser.Decode(&data); err != nil { + return nil, err + } + + stat, _ := os.Stat(filename) + + item := &JsonDashIndexItem{} + item.Dashboard = m.NewDashboardFromJson(data) + item.TitleLower = strings.ToLower(item.Dashboard.Title) + item.TagsCsv = strings.Join(item.Dashboard.GetTags(), ",") + item.Path = stat.Name() + + return item, nil +} diff --git a/pkg/services/search/json_index_test.go b/pkg/services/search/json_index_test.go new file mode 100644 index 00000000000..145e1ac1e99 --- /dev/null +++ b/pkg/services/search/json_index_test.go @@ -0,0 +1,42 @@ +package search + +import ( + "testing" + + . "github.com/smartystreets/goconvey/convey" +) + +func TestJsonDashIndex(t *testing.T) { + + Convey("Given the json dash index", t, func() { + index := NewJsonDashIndex("../../../public/dashboards/") + + Convey("Should be able to update index", func() { + err := index.updateIndex() + So(err, ShouldBeNil) + }) + + Convey("Should be able to search index", func() { + res, err := index.Search(&Query{Title: "", Limit: 20}) + So(err, ShouldBeNil) + + So(len(res), ShouldEqual, 3) + }) + + Convey("Should be able to search index by title", func() { + res, err := index.Search(&Query{Title: "home", Limit: 20}) + So(err, ShouldBeNil) + + So(len(res), ShouldEqual, 1) + So(res[0].Title, ShouldEqual, "Home") + }) + + Convey("Should not return when starred is filtered", func() { + res, err := index.Search(&Query{Title: "", IsStarred: true}) + So(err, ShouldBeNil) + + So(len(res), ShouldEqual, 0) + }) + + }) +} diff --git a/pkg/services/search/models.go b/pkg/services/search/models.go new file mode 100644 index 00000000000..9b8c7627f89 --- /dev/null +++ b/pkg/services/search/models.go @@ -0,0 +1,45 @@ +package search + +type HitType string + +const ( + DashHitDB HitType = "dash-db" + DashHitHome HitType = "dash-home" + DashHitJson HitType = "dash-json" + DashHitScripted HitType = "dash-scripted" +) + +type Hit struct { + Id int64 `json:"id"` + Title string `json:"title"` + Uri string `json:"uri"` + Type HitType `json:"type"` + Tags []string `json:"tags"` + IsStarred bool `json:"isStarred"` +} + +type HitList []*Hit + +func (s HitList) Len() int { return len(s) } +func (s HitList) Swap(i, j int) { s[i], s[j] = s[j], s[i] } +func (s HitList) Less(i, j int) bool { return s[i].Title < s[j].Title } + +type Query struct { + Title string + Tags []string + OrgId int64 + UserId int64 + Limit int + IsStarred bool + + Result HitList +} + +type FindPersistedDashboardsQuery struct { + Title string + OrgId int64 + UserId int64 + IsStarred bool + + Result HitList +} diff --git a/pkg/services/sqlstore/dashboard.go b/pkg/services/sqlstore/dashboard.go index 7dbebd94e4c..7fdaace316e 100644 --- a/pkg/services/sqlstore/dashboard.go +++ b/pkg/services/sqlstore/dashboard.go @@ -8,6 +8,7 @@ import ( "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/metrics" m "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/search" ) func init() { @@ -23,21 +24,17 @@ func SaveDashboard(cmd *m.SaveDashboardCommand) error { dash := cmd.GetDashboardModel() // try get existing dashboard - existing := m.Dashboard{Slug: dash.Slug, OrgId: dash.OrgId} - hasExisting, err := sess.Get(&existing) - if err != nil { - return err - } + var existing, sameTitle m.Dashboard - if hasExisting { - // another dashboard with same name - if dash.Id != existing.Id { - if cmd.Overwrite { - dash.Id = existing.Id - } else { - return m.ErrDashboardWithSameNameExists - } + if dash.Id > 0 { + dashWithIdExists, err := sess.Where("id=? AND org_id=?", dash.Id, dash.OrgId).Get(&existing) + if err != nil { + return err } + if !dashWithIdExists { + return m.ErrDashboardNotFound + } + // check for is someone else has written in between if dash.Version != existing.Version { if cmd.Overwrite { @@ -48,13 +45,35 @@ func SaveDashboard(cmd *m.SaveDashboardCommand) error { } } + sameTitleExists, err := sess.Where("org_id=? AND slug=?", dash.OrgId, dash.Slug).Get(&sameTitle) + if err != nil { + return err + } + + if sameTitleExists { + // another dashboard with same name + if dash.Id != sameTitle.Id { + if cmd.Overwrite { + dash.Id = sameTitle.Id + } else { + return m.ErrDashboardWithSameNameExists + } + } + } + + affectedRows := int64(0) + if dash.Id == 0 { metrics.M_Models_Dashboard_Insert.Inc(1) - _, err = sess.Insert(dash) + affectedRows, err = sess.Insert(dash) } else { dash.Version += 1 dash.Data["version"] = dash.Version - _, err = sess.Id(dash.Id).Update(dash) + affectedRows, err = sess.Id(dash.Id).Update(dash) + } + + if affectedRows == 0 { + return m.ErrDashboardNotFound } // delete existing tabs @@ -101,7 +120,7 @@ type DashboardSearchProjection struct { Term string } -func SearchDashboards(query *m.SearchDashboardsQuery) error { +func SearchDashboards(query *search.FindPersistedDashboardsQuery) error { var sql bytes.Buffer params := make([]interface{}, 0) @@ -131,16 +150,7 @@ func SearchDashboards(query *m.SearchDashboardsQuery) error { params = append(params, "%"+query.Title+"%") } - if len(query.Tag) > 0 { - sql.WriteString(" AND dashboard_tag.term=?") - params = append(params, query.Tag) - } - - if query.Limit == 0 || query.Limit > 10000 { - query.Limit = 300 - } - - sql.WriteString(fmt.Sprintf(" ORDER BY dashboard.title ASC LIMIT %d", query.Limit)) + sql.WriteString(fmt.Sprintf(" ORDER BY dashboard.title ASC LIMIT 1000")) var res []DashboardSearchProjection err := x.Sql(sql.String(), params...).Find(&res) @@ -148,16 +158,17 @@ func SearchDashboards(query *m.SearchDashboardsQuery) error { return err } - query.Result = make([]*m.DashboardSearchHit, 0) - hits := make(map[int64]*m.DashboardSearchHit) + query.Result = make([]*search.Hit, 0) + hits := make(map[int64]*search.Hit) for _, item := range res { hit, exists := hits[item.Id] if !exists { - hit = &m.DashboardSearchHit{ + hit = &search.Hit{ Id: item.Id, Title: item.Title, - Slug: item.Slug, + Uri: "db/" + item.Slug, + Type: search.DashHitDB, Tags: []string{}, } query.Result = append(query.Result, hit) diff --git a/pkg/services/sqlstore/dashboard_snapshot.go b/pkg/services/sqlstore/dashboard_snapshot.go index 0bbb01ed6bd..f4611050a77 100644 --- a/pkg/services/sqlstore/dashboard_snapshot.go +++ b/pkg/services/sqlstore/dashboard_snapshot.go @@ -57,7 +57,7 @@ func GetDashboardSnapshot(query *m.GetDashboardSnapshotQuery) error { if err != nil { return err } else if has == false { - return m.ErrNotFound + return m.ErrDashboardSnapshotNotFound } query.Result = &snapshot diff --git a/pkg/services/sqlstore/dashboard_test.go b/pkg/services/sqlstore/dashboard_test.go index c7a8053d528..0d4eb111868 100644 --- a/pkg/services/sqlstore/dashboard_test.go +++ b/pkg/services/sqlstore/dashboard_test.go @@ -6,6 +6,7 @@ import ( . "github.com/smartystreets/goconvey/convey" m "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/search" ) func insertTestDashboard(title string, orgId int64, tags ...interface{}) *m.Dashboard { @@ -51,8 +52,41 @@ func TestDashboardDataAccess(t *testing.T) { So(query.Result.Slug, ShouldEqual, "test-dash-23") }) + Convey("Should return error if no dashboard is updated", func() { + cmd := m.SaveDashboardCommand{ + OrgId: 1, + Overwrite: true, + Dashboard: map[string]interface{}{ + "id": float64(123412321), + "title": "Expect error", + "tags": []interface{}{}, + }, + } + + err := SaveDashboard(&cmd) + So(err, ShouldNotBeNil) + }) + + Convey("Should not be able to overwrite dashboard in another org", func() { + query := m.GetDashboardQuery{Slug: "test-dash-23", OrgId: 1} + GetDashboard(&query) + + cmd := m.SaveDashboardCommand{ + OrgId: 2, + Overwrite: true, + Dashboard: map[string]interface{}{ + "id": float64(query.Result.Id), + "title": "Expect error", + "tags": []interface{}{}, + }, + } + + err := SaveDashboard(&cmd) + So(err, ShouldNotBeNil) + }) + Convey("Should be able to search for dashboard", func() { - query := m.SearchDashboardsQuery{ + query := search.FindPersistedDashboardsQuery{ Title: "test", OrgId: 1, } @@ -65,18 +99,6 @@ func TestDashboardDataAccess(t *testing.T) { So(len(hit.Tags), ShouldEqual, 2) }) - Convey("Should be able to search for dashboards using tags", func() { - query1 := m.SearchDashboardsQuery{Tag: "webapp", OrgId: 1} - query2 := m.SearchDashboardsQuery{Tag: "tagdoesnotexist", OrgId: 1} - - err := SearchDashboards(&query1) - err = SearchDashboards(&query2) - So(err, ShouldBeNil) - - So(len(query1.Result), ShouldEqual, 1) - So(len(query2.Result), ShouldEqual, 0) - }) - Convey("Should not be able to save dashboard with same name", func() { cmd := m.SaveDashboardCommand{ OrgId: 1, @@ -113,7 +135,7 @@ func TestDashboardDataAccess(t *testing.T) { }) Convey("Should be able to search for starred dashboards", func() { - query := m.SearchDashboardsQuery{OrgId: 1, UserId: 10, IsStarred: true} + query := search.FindPersistedDashboardsQuery{OrgId: 1, UserId: 10, IsStarred: true} err := SearchDashboards(&query) So(err, ShouldBeNil) diff --git a/pkg/services/sqlstore/migrations/dashboard_mig.go b/pkg/services/sqlstore/migrations/dashboard_mig.go index 6ded17ff0e7..5d440d85ebc 100644 --- a/pkg/services/sqlstore/migrations/dashboard_mig.go +++ b/pkg/services/sqlstore/migrations/dashboard_mig.go @@ -86,4 +86,10 @@ func addDashboardMigration(mg *Migrator) { })) mg.AddMigration("drop table dashboard_v1", NewDropTableMigration("dashboard_v1")) + + // change column type of dashboard.data + mg.AddMigration("alter dashboard.data to mediumtext v1", new(RawSqlMigration). + Sqlite("SELECT 0 WHERE 0;"). + Postgres("SELECT 0;"). + Mysql("ALTER TABLE dashboard MODIFY data MEDIUMTEXT;")) } diff --git a/pkg/services/sqlstore/migrations/dashboard_snapshot_mig.go b/pkg/services/sqlstore/migrations/dashboard_snapshot_mig.go index 4d83dfd5bc6..b08cc451e55 100644 --- a/pkg/services/sqlstore/migrations/dashboard_snapshot_mig.go +++ b/pkg/services/sqlstore/migrations/dashboard_snapshot_mig.go @@ -48,4 +48,10 @@ func addDashboardSnapshotMigrations(mg *Migrator) { mg.AddMigration("create dashboard_snapshot table v5 #2", NewAddTableMigration(snapshotV5)) addTableIndicesMigrations(mg, "v5", snapshotV5) + + // change column type of dashboard + mg.AddMigration("alter dashboard_snapshot to mediumtext v2", new(RawSqlMigration). + Sqlite("SELECT 0 WHERE 0;"). + Postgres("SELECT 0;"). + Mysql("ALTER TABLE dashboard_snapshot MODIFY dashboard MEDIUMTEXT;")) } diff --git a/pkg/services/sqlstore/migrations/migrations_test.go b/pkg/services/sqlstore/migrations/migrations_test.go index d4ba97450f7..0278ea6632b 100644 --- a/pkg/services/sqlstore/migrations/migrations_test.go +++ b/pkg/services/sqlstore/migrations/migrations_test.go @@ -1,12 +1,9 @@ package migrations import ( - "fmt" - "strings" "testing" "github.com/go-xorm/xorm" - "github.com/grafana/grafana/pkg/log" . "github.com/grafana/grafana/pkg/services/sqlstore/migrator" "github.com/grafana/grafana/pkg/services/sqlstore/sqlutil" @@ -16,7 +13,7 @@ import ( var indexTypes = []string{"Unknown", "INDEX", "UNIQUE INDEX"} func TestMigrations(t *testing.T) { - log.NewLogger(0, "console", `{"level": 0}`) + //log.NewLogger(0, "console", `{"level": 0}`) testDBs := []sqlutil.TestDB{ sqlutil.TestDB_Sqlite3, @@ -31,30 +28,30 @@ func TestMigrations(t *testing.T) { sqlutil.CleanDB(x) mg := NewMigrator(x) - mg.LogLevel = log.DEBUG + //mg.LogLevel = log.DEBUG AddMigrations(mg) err = mg.Start() So(err, ShouldBeNil) - tables, err := x.DBMetas() - So(err, ShouldBeNil) - - fmt.Printf("\nDB Schema after migration: table count: %v\n", len(tables)) - - for _, table := range tables { - fmt.Printf("\nTable: %v \n", table.Name) - for _, column := range table.Columns() { - fmt.Printf("\t %v \n", column.String(x.Dialect())) - } - - if len(table.Indexes) > 0 { - fmt.Printf("\n\tIndexes:\n") - for _, index := range table.Indexes { - fmt.Printf("\t %v (%v) %v \n", index.Name, strings.Join(index.Cols, ","), indexTypes[index.Type]) - } - } - } + // tables, err := x.DBMetas() + // So(err, ShouldBeNil) + // + // fmt.Printf("\nDB Schema after migration: table count: %v\n", len(tables)) + // + // for _, table := range tables { + // fmt.Printf("\nTable: %v \n", table.Name) + // for _, column := range table.Columns() { + // fmt.Printf("\t %v \n", column.String(x.Dialect())) + // } + // + // if len(table.Indexes) > 0 { + // fmt.Printf("\n\tIndexes:\n") + // for _, index := range table.Indexes { + // fmt.Printf("\t %v (%v) %v \n", index.Name, strings.Join(index.Cols, ","), indexTypes[index.Type]) + // } + // } + // } }) } } diff --git a/pkg/services/sqlstore/migrator/migrations.go b/pkg/services/sqlstore/migrator/migrations.go index e596ef6c171..a65c7ec7e81 100644 --- a/pkg/services/sqlstore/migrator/migrations.go +++ b/pkg/services/sqlstore/migrator/migrations.go @@ -25,8 +25,9 @@ func (m *MigrationBase) GetCondition() MigrationCondition { type RawSqlMigration struct { MigrationBase - sqlite string - mysql string + sqlite string + mysql string + postgres string } func (m *RawSqlMigration) Sql(dialect Dialect) string { @@ -35,6 +36,8 @@ func (m *RawSqlMigration) Sql(dialect Dialect) string { return m.mysql case SQLITE: return m.sqlite + case POSTGRES: + return m.postgres } panic("db type not supported") @@ -50,6 +53,11 @@ func (m *RawSqlMigration) Mysql(sql string) *RawSqlMigration { return m } +func (m *RawSqlMigration) Postgres(sql string) *RawSqlMigration { + m.postgres = sql + return m +} + type AddColumnMigration struct { MigrationBase tableName string diff --git a/pkg/services/sqlstore/org.go b/pkg/services/sqlstore/org.go index 18284feccac..725a21d7fad 100644 --- a/pkg/services/sqlstore/org.go +++ b/pkg/services/sqlstore/org.go @@ -14,12 +14,23 @@ func init() { bus.AddHandler("sql", CreateOrg) bus.AddHandler("sql", UpdateOrg) bus.AddHandler("sql", GetOrgByName) - bus.AddHandler("sql", GetOrgList) + bus.AddHandler("sql", SearchOrgs) bus.AddHandler("sql", DeleteOrg) } -func GetOrgList(query *m.GetOrgListQuery) error { - return x.Find(&query.Result) +func SearchOrgs(query *m.SearchOrgsQuery) error { + query.Result = make([]*m.OrgDTO, 0) + sess := x.Table("org") + if query.Query != "" { + sess.Where("name LIKE ?", query.Query+"%") + } + if query.Name != "" { + sess.Where("name=?", query.Name) + } + sess.Limit(query.Limit, query.Limit*query.Page) + sess.Cols("id", "name") + err := sess.Find(&query.Result) + return err } func GetOrgById(query *m.GetOrgByIdQuery) error { diff --git a/pkg/services/sqlstore/org_test.go b/pkg/services/sqlstore/org_test.go index 0e20919709b..f52175c2e5c 100644 --- a/pkg/services/sqlstore/org_test.go +++ b/pkg/services/sqlstore/org_test.go @@ -10,7 +10,6 @@ import ( ) func TestAccountDataAccess(t *testing.T) { - Convey("Testing Account DB Access", t, func() { InitTestDB(t) @@ -80,6 +79,19 @@ func TestAccountDataAccess(t *testing.T) { So(err, ShouldBeNil) }) + Convey("Can update org user role", func() { + updateCmd := m.UpdateOrgUserCommand{OrgId: ac1.OrgId, UserId: ac2.Id, Role: m.ROLE_ADMIN} + err = UpdateOrgUser(&updateCmd) + So(err, ShouldBeNil) + + orgUsersQuery := m.GetOrgUsersQuery{OrgId: ac1.OrgId} + err = GetOrgUsers(&orgUsersQuery) + So(err, ShouldBeNil) + + So(orgUsersQuery.Result[1].Role, ShouldEqual, m.ROLE_ADMIN) + + }) + Convey("Can get logged in user projection", func() { query := m.GetSignedInUserQuery{UserId: ac2.Id} err := GetSignedInUser(&query) @@ -130,11 +142,18 @@ func TestAccountDataAccess(t *testing.T) { }) }) - Convey("Cannot delete last admin account user", func() { + Convey("Cannot delete last admin org user", func() { cmd := m.RemoveOrgUserCommand{OrgId: ac1.OrgId, UserId: ac1.Id} err := RemoveOrgUser(&cmd) So(err, ShouldEqual, m.ErrLastOrgAdmin) }) + + Convey("Cannot update role so no one is admin user", func() { + cmd := m.UpdateOrgUserCommand{OrgId: ac1.OrgId, UserId: ac1.Id, Role: m.ROLE_VIEWER} + err := UpdateOrgUser(&cmd) + So(err, ShouldEqual, m.ErrLastOrgAdmin) + }) + }) }) }) diff --git a/pkg/services/sqlstore/org_users.go b/pkg/services/sqlstore/org_users.go index eaca01ce12c..2e8fc40cb7c 100644 --- a/pkg/services/sqlstore/org_users.go +++ b/pkg/services/sqlstore/org_users.go @@ -14,6 +14,7 @@ func init() { bus.AddHandler("sql", AddOrgUser) bus.AddHandler("sql", RemoveOrgUser) bus.AddHandler("sql", GetOrgUsers) + bus.AddHandler("sql", UpdateOrgUser) } func AddOrgUser(cmd *m.AddOrgUserCommand) error { @@ -32,6 +33,29 @@ func AddOrgUser(cmd *m.AddOrgUserCommand) error { }) } +func UpdateOrgUser(cmd *m.UpdateOrgUserCommand) error { + return inTransaction(func(sess *xorm.Session) error { + var orgUser m.OrgUser + exists, err := sess.Where("org_id=? AND user_id=?", cmd.OrgId, cmd.UserId).Get(&orgUser) + if err != nil { + return err + } + + if !exists { + return m.ErrOrgUserNotFound + } + + orgUser.Role = cmd.Role + orgUser.Updated = time.Now() + _, err = sess.Id(orgUser.Id).Update(&orgUser) + if err != nil { + return err + } + + return validateOneAdminLeftInOrg(cmd.OrgId, sess) + }) +} + func GetOrgUsers(query *m.GetOrgUsersQuery) error { query.Result = make([]*m.OrgUserDTO, 0) sess := x.Table("org_user") @@ -52,16 +76,20 @@ func RemoveOrgUser(cmd *m.RemoveOrgUserCommand) error { return err } - // validate that there is an admin user left - res, err := sess.Query("SELECT 1 from org_user WHERE org_id=? and role='Admin'", cmd.OrgId) - if err != nil { - return err - } - - if len(res) == 0 { - return m.ErrLastOrgAdmin - } - - return err + return validateOneAdminLeftInOrg(cmd.OrgId, sess) }) } + +func validateOneAdminLeftInOrg(orgId int64, sess *xorm.Session) error { + // validate that there is an admin user left + res, err := sess.Query("SELECT 1 from org_user WHERE org_id=? and role='Admin'", orgId) + if err != nil { + return err + } + + if len(res) == 0 { + return m.ErrLastOrgAdmin + } + + return err +} diff --git a/pkg/services/sqlstore/user.go b/pkg/services/sqlstore/user.go index 61798d724ef..f5df6f9ff1f 100644 --- a/pkg/services/sqlstore/user.go +++ b/pkg/services/sqlstore/user.go @@ -231,10 +231,12 @@ func GetUserProfile(query *m.GetUserProfileQuery) error { } query.Result = m.UserProfileDTO{ - Name: user.Name, - Email: user.Email, - Login: user.Login, - Theme: user.Theme, + Name: user.Name, + Email: user.Email, + Login: user.Login, + Theme: user.Theme, + IsGrafanaAdmin: user.IsAdmin, + OrgId: user.OrgId, } return err @@ -263,18 +265,30 @@ func GetSignedInUser(query *m.GetSignedInUserQuery) error { org.id as org_id FROM ` + dialect.Quote("user") + ` as u LEFT OUTER JOIN org_user on org_user.org_id = u.org_id and org_user.user_id = u.id - LEFT OUTER JOIN org on org.id = u.org_id - WHERE u.id=?` + LEFT OUTER JOIN org on org.id = u.org_id ` + + sess := x.Table("user") + if query.UserId > 0 { + sess.Sql(rawSql+"WHERE u.id=?", query.UserId) + } else if query.Login != "" { + sess.Sql(rawSql+"WHERE u.login=?", query.Login) + } else if query.Email != "" { + sess.Sql(rawSql+"WHERE u.email=?", query.Email) + } var user m.SignedInUser - sess := x.Table("user") - has, err := sess.Sql(rawSql, query.UserId).Get(&user) + has, err := sess.Get(&user) if err != nil { return err } else if !has { return m.ErrUserNotFound } + if user.OrgRole == "" { + user.OrgId = -1 + user.OrgName = "Org missing" + } + query.Result = &user return err } diff --git a/pkg/services/sqlstore/xorm.log b/pkg/services/sqlstore/xorm.log deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/pkg/setting/setting.go b/pkg/setting/setting.go index 46f98e32efe..d0f28fb809d 100644 --- a/pkg/setting/setting.go +++ b/pkg/setting/setting.go @@ -38,7 +38,6 @@ const ( var ( // App settings. Env string = DEV - AppName string AppUrl string AppSubUrl string @@ -65,12 +64,15 @@ var ( RouterLogging bool StaticRootPath string EnableGzip bool + EnforceDomain bool // Security settings. - SecretKey string - LogInRememberDays int - CookieUserName string - CookieRememberName string + SecretKey string + LogInRememberDays int + CookieUserName string + CookieRememberName string + DisableGravatar bool + EmailCodeValidMinutes int // User settings AllowUserSignUp bool @@ -86,6 +88,15 @@ var ( AnonymousOrgName string AnonymousOrgRole string + // Auth proxy settings + AuthProxyEnabled bool + AuthProxyHeaderName string + AuthProxyHeaderProperty string + AuthProxyAutoSignUp bool + + // Basic Auth + BasicAuthEnabled bool + // Session settings. SessionOptions session.Options @@ -105,6 +116,9 @@ var ( ReportingEnabled bool GoogleAnalyticsId string + + // SMTP email settings + Smtp SmtpSettings ) type CommandLineArgs struct { @@ -250,11 +264,13 @@ func loadSpecifedConfigFile(configFile string) { defaultSec, err := Cfg.GetSection(section.Name()) if err != nil { - log.Fatal(3, "Unknown config section %s defined in %s", section.Name(), configFile) + log.Error(3, "Unknown config section %s defined in %s", section.Name(), configFile) + continue } defaultKey, err := defaultSec.GetKey(key.Name()) if err != nil { - log.Fatal(3, "Unknown config key %s defined in section %s, in file", key.Name(), section.Name(), configFile) + log.Error(3, "Unknown config key %s defined in section %s, in file", key.Name(), section.Name(), configFile) + continue } defaultKey.SetValue(key.Value()) } @@ -279,10 +295,13 @@ func loadConfiguration(args *CommandLineArgs) { // command line props commandLineProps := getCommandLineProperties(args.Args) - // load default overrides applyCommandLineDefaultProperties(commandLineProps) + // init logging before specific config so we can log errors from here on + DataPath = makeAbsolute(Cfg.Section("paths").Key("data").String(), HomePath) + initLogging(args) + // load specified config file loadSpecifedConfigFile(args.Config) @@ -294,6 +313,10 @@ func loadConfiguration(args *CommandLineArgs) { // evaluate config values containing environment variables evalConfigValues() + + // update data path and logging config + DataPath = makeAbsolute(Cfg.Section("paths").Key("data").String(), HomePath) + initLogging(args) } func pathExists(path string) bool { @@ -329,10 +352,6 @@ func NewConfigContext(args *CommandLineArgs) { setHomePath(args) loadConfiguration(args) - DataPath = makeAbsolute(Cfg.Section("paths").Key("data").String(), HomePath) - initLogging(args) - - AppName = Cfg.Section("").Key("app_name").MustString("Grafana") Env = Cfg.Section("").Key("app_mode").MustString("development") server := Cfg.Section("server") @@ -348,16 +367,18 @@ func NewConfigContext(args *CommandLineArgs) { Domain = server.Key("domain").MustString("localhost") HttpAddr = server.Key("http_addr").MustString("0.0.0.0") HttpPort = server.Key("http_port").MustString("3000") - StaticRootPath = makeAbsolute(server.Key("static_root_path").String(), HomePath) RouterLogging = server.Key("router_logging").MustBool(false) EnableGzip = server.Key("enable_gzip").MustBool(false) + EnforceDomain = server.Key("enforce_domain").MustBool(false) security := Cfg.Section("security") SecretKey = security.Key("secret_key").String() LogInRememberDays = security.Key("login_remember_days").MustInt() CookieUserName = security.Key("cookie_username").String() CookieRememberName = security.Key("cookie_remember_name").String() + DisableGravatar = security.Key("disable_gravatar").MustBool(true) + // admin AdminUser = security.Key("admin_user").String() AdminPassword = security.Key("admin_password").String() @@ -373,6 +394,16 @@ func NewConfigContext(args *CommandLineArgs) { AnonymousOrgName = Cfg.Section("auth.anonymous").Key("org_name").String() AnonymousOrgRole = Cfg.Section("auth.anonymous").Key("org_role").String() + // auth proxy + authProxy := Cfg.Section("auth.proxy") + AuthProxyEnabled = authProxy.Key("enabled").MustBool(false) + AuthProxyHeaderName = authProxy.Key("header_name").String() + AuthProxyHeaderProperty = authProxy.Key("header_property").String() + AuthProxyAutoSignUp = authProxy.Key("auto_sign_up").MustBool(true) + + authBasic := Cfg.Section("auth.basic") + BasicAuthEnabled = authBasic.Key("enabled").MustBool(true) + // PhantomJS rendering ImagesDir = filepath.Join(DataPath, "png") PhantomDir = filepath.Join(HomePath, "vendor/phantomjs") @@ -382,6 +413,7 @@ func NewConfigContext(args *CommandLineArgs) { GoogleAnalyticsId = analytics.Key("google_analytics_ua_id").String() readSessionConfig() + readSmtpSettings() } func readSessionConfig() { diff --git a/pkg/setting/setting_smtp.go b/pkg/setting/setting_smtp.go new file mode 100644 index 00000000000..e84b61634cc --- /dev/null +++ b/pkg/setting/setting_smtp.go @@ -0,0 +1,31 @@ +package setting + +type SmtpSettings struct { + Enabled bool + Host string + User string + Password string + CertFile string + KeyFile string + FromAddress string + SkipVerify bool + + SendWelcomeEmailOnSignUp bool + TemplatesPattern string +} + +func readSmtpSettings() { + sec := Cfg.Section("smtp") + Smtp.Enabled = sec.Key("enabled").MustBool(false) + Smtp.Host = sec.Key("host").String() + Smtp.User = sec.Key("user").String() + Smtp.Password = sec.Key("password").String() + Smtp.CertFile = sec.Key("cert_file").String() + Smtp.KeyFile = sec.Key("key_file").String() + Smtp.FromAddress = sec.Key("from_address").String() + Smtp.SkipVerify = sec.Key("skip_verify").MustBool(false) + + emails := Cfg.Section("emails") + Smtp.SendWelcomeEmailOnSignUp = emails.Key("welcome_email_on_sign_up").MustBool(false) + Smtp.TemplatesPattern = emails.Key("templates_pattern").MustString("emails/*.html") +} diff --git a/pkg/setting/setting_test.go b/pkg/setting/setting_test.go index 73ccabd2dbc..00da9b4f416 100644 --- a/pkg/setting/setting_test.go +++ b/pkg/setting/setting_test.go @@ -15,7 +15,6 @@ func TestLoadingSettings(t *testing.T) { Convey("Given the default ini files", func() { NewConfigContext(&CommandLineArgs{HomePath: "../../"}) - So(AppName, ShouldEqual, "Grafana") So(AdminUser, ShouldEqual, "admin") }) diff --git a/pkg/social/social.go b/pkg/social/social.go index 47c7ea5dc38..1a00934b937 100644 --- a/pkg/social/social.go +++ b/pkg/social/social.go @@ -2,7 +2,9 @@ package social import ( "encoding/json" + "errors" "fmt" + "net/http" "strconv" "strings" @@ -75,13 +77,26 @@ func NewOAuthService() { // GitHub. if name == "github" { setting.OAuthService.GitHub = true - SocialMap["github"] = &SocialGithub{Config: &config, allowedDomains: info.AllowedDomains, ApiUrl: info.ApiUrl, allowSignup: info.AllowSignup} + teamIds := sec.Key("team_ids").Ints(",") + allowedOrganizations := sec.Key("allowed_organizations").Strings(" ") + SocialMap["github"] = &SocialGithub{ + Config: &config, + allowedDomains: info.AllowedDomains, + apiUrl: info.ApiUrl, + allowSignup: info.AllowSignup, + teamIds: teamIds, + allowedOrganizations: allowedOrganizations, + } } // Google. if name == "google" { setting.OAuthService.Google = true - SocialMap["google"] = &SocialGoogle{Config: &config, allowedDomains: info.AllowedDomains, ApiUrl: info.ApiUrl, allowSignup: info.AllowSignup} + SocialMap["google"] = &SocialGoogle{ + Config: &config, allowedDomains: info.AllowedDomains, + apiUrl: info.ApiUrl, + allowSignup: info.AllowSignup, + } } } } @@ -102,11 +117,21 @@ func isEmailAllowed(email string, allowedDomains []string) bool { type SocialGithub struct { *oauth2.Config - allowedDomains []string - ApiUrl string - allowSignup bool + allowedDomains []string + allowedOrganizations []string + apiUrl string + allowSignup bool + teamIds []int } +var ( + ErrMissingTeamMembership = errors.New("User not a member of one of the required teams") +) + +var ( + ErrMissingOrganizationMembership = errors.New("User not a member of one of the required organizations") +) + func (s *SocialGithub) Type() int { return int(models.GITHUB) } @@ -119,6 +144,133 @@ func (s *SocialGithub) IsSignupAllowed() bool { return s.allowSignup } +func (s *SocialGithub) IsTeamMember(client *http.Client) bool { + if len(s.teamIds) == 0 { + return true + } + + teamMemberships, err := s.FetchTeamMemberships(client) + if err != nil { + return false + } + + for _, teamId := range s.teamIds { + for _, membershipId := range teamMemberships { + if teamId == membershipId { + return true + } + } + } + + return false +} + +func (s *SocialGithub) IsOrganizationMember(client *http.Client) bool { + if len(s.allowedOrganizations) == 0 { + return true + } + + organizations, err := s.FetchOrganizations(client) + if err != nil { + return false + } + + for _, allowedOrganization := range s.allowedOrganizations { + for _, organization := range organizations { + if organization == allowedOrganization { + return true + } + } + } + + return false +} + +func (s *SocialGithub) FetchPrivateEmail(client *http.Client) (string, error) { + type Record struct { + Email string `json:"email"` + Primary bool `json:"primary"` + Verified bool `json:"verified"` + } + + emailsUrl := fmt.Sprintf("https://api.github.com/user/emails") + r, err := client.Get(emailsUrl) + if err != nil { + return "", err + } + + defer r.Body.Close() + + var records []Record + + if err = json.NewDecoder(r.Body).Decode(&records); err != nil { + return "", err + } + + var email = "" + for _, record := range records { + if record.Primary { + email = record.Email + } + } + + return email, nil +} + +func (s *SocialGithub) FetchTeamMemberships(client *http.Client) ([]int, error) { + type Record struct { + Id int `json:"id"` + } + + membershipUrl := fmt.Sprintf("https://api.github.com/user/teams") + r, err := client.Get(membershipUrl) + if err != nil { + return nil, err + } + + defer r.Body.Close() + + var records []Record + + if err = json.NewDecoder(r.Body).Decode(&records); err != nil { + return nil, err + } + + var ids = make([]int, len(records)) + for i, record := range records { + ids[i] = record.Id + } + + return ids, nil +} + +func (s *SocialGithub) FetchOrganizations(client *http.Client) ([]string, error) { + type Record struct { + Login string `json:"login"` + } + + url := fmt.Sprintf("https://api.github.com/user/orgs") + r, err := client.Get(url) + if err != nil { + return nil, err + } + + defer r.Body.Close() + + var records []Record + + if err = json.NewDecoder(r.Body).Decode(&records); err != nil { + return nil, err + } + + var logins = make([]string, len(records)) + for i, record := range records { + logins[i] = record.Login + } + + return logins, nil +} + func (s *SocialGithub) UserInfo(token *oauth2.Token) (*BasicUserInfo, error) { var data struct { Id int `json:"id"` @@ -128,7 +280,7 @@ func (s *SocialGithub) UserInfo(token *oauth2.Token) (*BasicUserInfo, error) { var err error client := s.Client(oauth2.NoContext, token) - r, err := client.Get(s.ApiUrl) + r, err := client.Get(s.apiUrl) if err != nil { return nil, err } @@ -139,11 +291,28 @@ func (s *SocialGithub) UserInfo(token *oauth2.Token) (*BasicUserInfo, error) { return nil, err } - return &BasicUserInfo{ + userInfo := &BasicUserInfo{ Identity: strconv.Itoa(data.Id), Name: data.Name, Email: data.Email, - }, nil + } + + if !s.IsTeamMember(client) { + return nil, ErrMissingTeamMembership + } + + if !s.IsOrganizationMember(client) { + return nil, ErrMissingOrganizationMembership + } + + if userInfo.Email == "" { + userInfo.Email, err = s.FetchPrivateEmail(client) + if err != nil { + return nil, err + } + } + + return userInfo, nil } // ________ .__ @@ -156,7 +325,7 @@ func (s *SocialGithub) UserInfo(token *oauth2.Token) (*BasicUserInfo, error) { type SocialGoogle struct { *oauth2.Config allowedDomains []string - ApiUrl string + apiUrl string allowSignup bool } @@ -181,7 +350,7 @@ func (s *SocialGoogle) UserInfo(token *oauth2.Token) (*BasicUserInfo, error) { var err error client := s.Client(oauth2.NoContext, token) - r, err := client.Get(s.ApiUrl) + r, err := client.Get(s.apiUrl) if err != nil { return nil, err } diff --git a/pkg/util/encoding.go b/pkg/util/encoding.go index 27169133a42..e87da9d3d55 100644 --- a/pkg/util/encoding.go +++ b/pkg/util/encoding.go @@ -7,8 +7,10 @@ import ( "crypto/sha256" "encoding/base64" "encoding/hex" + "errors" "fmt" "hash" + "strings" ) // source: https://github.com/gogits/gogs/blob/9ee80e3e5426821f03a4e99fad34418f5c736413/modules/base/tool.go#L58 @@ -80,3 +82,23 @@ func GetBasicAuthHeader(user string, password string) string { var userAndPass = user + ":" + password return "Basic " + base64.StdEncoding.EncodeToString([]byte(userAndPass)) } + +func DecodeBasicAuthHeader(header string) (string, string, error) { + var code string + parts := strings.SplitN(header, " ", 2) + if len(parts) == 2 && parts[0] == "Basic" { + code = parts[1] + } + + decoded, err := base64.StdEncoding.DecodeString(code) + if err != nil { + return "", "", err + } + + userAndPass := strings.SplitN(string(decoded), ":", 2) + if len(userAndPass) != 2 { + return "", "", errors.New("Invalid basic auth header") + } + + return userAndPass[0], userAndPass[1], nil +} diff --git a/pkg/util/encoding_test.go b/pkg/util/encoding_test.go index afe299f9f92..abcf5425826 100644 --- a/pkg/util/encoding_test.go +++ b/pkg/util/encoding_test.go @@ -13,4 +13,14 @@ func TestEncoding(t *testing.T) { So(result, ShouldEqual, "Basic Z3JhZmFuYToxMjM0") }) + + Convey("When decoding basic auth header", t, func() { + header := GetBasicAuthHeader("grafana", "1234") + username, password, err := DecodeBasicAuthHeader(header) + So(err, ShouldBeNil) + + So(username, ShouldEqual, "grafana") + So(password, ShouldEqual, "1234") + }) + } diff --git a/pkg/util/validation.go b/pkg/util/validation.go new file mode 100644 index 00000000000..dd7404e6de4 --- /dev/null +++ b/pkg/util/validation.go @@ -0,0 +1,18 @@ +package util + +import ( + "regexp" + "strings" +) + +const ( + emailRegexPattern string = "^(((([a-zA-Z]|\\d|[!#\\$%&'\\*\\+\\-\\/=\\?\\^_`{\\|}~]|[\\x{00A0}-\\x{D7FF}\\x{F900}-\\x{FDCF}\\x{FDF0}-\\x{FFEF}])+(\\.([a-zA-Z]|\\d|[!#\\$%&'\\*\\+\\-\\/=\\?\\^_`{\\|}~]|[\\x{00A0}-\\x{D7FF}\\x{F900}-\\x{FDCF}\\x{FDF0}-\\x{FFEF}])+)*)|((\\x22)((((\\x20|\\x09)*(\\x0d\\x0a))?(\\x20|\\x09)+)?(([\\x01-\\x08\\x0b\\x0c\\x0e-\\x1f\\x7f]|\\x21|[\\x23-\\x5b]|[\\x5d-\\x7e]|[\\x{00A0}-\\x{D7FF}\\x{F900}-\\x{FDCF}\\x{FDF0}-\\x{FFEF}])|(\\([\\x01-\\x09\\x0b\\x0c\\x0d-\\x7f]|[\\x{00A0}-\\x{D7FF}\\x{F900}-\\x{FDCF}\\x{FDF0}-\\x{FFEF}]))))*(((\\x20|\\x09)*(\\x0d\\x0a))?(\\x20|\\x09)+)?(\\x22)))@((([a-zA-Z]|\\d|[\\x{00A0}-\\x{D7FF}\\x{F900}-\\x{FDCF}\\x{FDF0}-\\x{FFEF}])|(([a-zA-Z]|\\d|[\\x{00A0}-\\x{D7FF}\\x{F900}-\\x{FDCF}\\x{FDF0}-\\x{FFEF}])([a-zA-Z]|\\d|-|\\.|_|~|[\\x{00A0}-\\x{D7FF}\\x{F900}-\\x{FDCF}\\x{FDF0}-\\x{FFEF}])*([a-zA-Z]|\\d|[\\x{00A0}-\\x{D7FF}\\x{F900}-\\x{FDCF}\\x{FDF0}-\\x{FFEF}])))\\.)+(([a-zA-Z]|[\\x{00A0}-\\x{D7FF}\\x{F900}-\\x{FDCF}\\x{FDF0}-\\x{FFEF}])|(([a-zA-Z]|[\\x{00A0}-\\x{D7FF}\\x{F900}-\\x{FDCF}\\x{FDF0}-\\x{FFEF}])([a-zA-Z]|\\d|-|\\.|_|~|[\\x{00A0}-\\x{D7FF}\\x{F900}-\\x{FDCF}\\x{FDF0}-\\x{FFEF}])*([a-zA-Z]|[\\x{00A0}-\\x{D7FF}\\x{F900}-\\x{FDCF}\\x{FDF0}-\\x{FFEF}])))\\.?$" +) + +var ( + regexEmail = regexp.MustCompile(emailRegexPattern) +) + +func IsEmail(str string) bool { + return regexEmail.MatchString(strings.ToLower(str)) +} diff --git a/public/app/components/extend-jquery.js b/public/app/components/extend-jquery.js index ce5c8afb1a9..f44245103b5 100644 --- a/public/app/components/extend-jquery.js +++ b/public/app/components/extend-jquery.js @@ -1,5 +1,5 @@ -define(['jquery'], -function ($) { +define(['jquery', 'angular', 'lodash'], +function ($, angular, _) { 'use strict'; /** @@ -14,6 +14,7 @@ function ($) { return function (x, y, opts) { opts = $.extend(true, {}, defaults, opts); + return this.each(function () { var $tooltip = $(this), width, height; @@ -22,6 +23,17 @@ function ($) { $("#tooltip").remove(); $tooltip.appendTo(document.body); + if (opts.compile) { + angular.element(document).injector().invoke(["$compile", "$rootScope", function($compile, $rootScope) { + var tmpScope = $rootScope.$new(true); + _.extend(tmpScope, opts.scopeData); + + $compile($tooltip)(tmpScope); + tmpScope.$digest(); + tmpScope.$destroy(); + }]); + } + width = $tooltip.outerWidth(true); height = $tooltip.outerHeight(true); diff --git a/public/app/components/kbn.js b/public/app/components/kbn.js index 1193285787f..d817ada2ebe 100644 --- a/public/app/components/kbn.js +++ b/public/app/components/kbn.js @@ -81,11 +81,16 @@ function($, _, moment) { if(numminutes){ return numminutes + 'm'; } - var numseconds = (((seconds % 31536000) % 86400) % 3600) % 60; + var numseconds = Math.floor((((seconds % 31536000) % 86400) % 3600) % 60); if(numseconds){ return numseconds + 's'; } - return 'less then a second'; //'just now' //or other string you like; + var nummilliseconds = Math.floor(seconds * 1000.0); + if(nummilliseconds){ + return nummilliseconds + 'ms'; + } + + return 'less then a millisecond'; //'just now' //or other string you like; }; kbn.to_percent = function(number,outof) { @@ -376,10 +381,15 @@ function($, _, moment) { kbn.valueFormats.bytes = kbn.formatFuncCreator(1024, [' B', ' KiB', ' MiB', ' GiB', ' TiB', ' PiB', ' EiB', ' ZiB', ' YiB']); kbn.valueFormats.kbytes = kbn.formatFuncCreator(1024, [' KiB', ' MiB', ' GiB', ' TiB', ' PiB', ' EiB', ' ZiB', ' YiB']); kbn.valueFormats.mbytes = kbn.formatFuncCreator(1024, [' MiB', ' GiB', ' TiB', ' PiB', ' EiB', ' ZiB', ' YiB']); + kbn.valueFormats.gbytes = kbn.formatFuncCreator(1024, [' GiB', ' TiB', ' PiB', ' EiB', ' ZiB', ' YiB']); kbn.valueFormats.bps = kbn.formatFuncCreator(1000, [' bps', ' Kbps', ' Mbps', ' Gbps', ' Tbps', ' Pbps', ' Ebps', ' Zbps', ' Ybps']); + kbn.valueFormats.pps = kbn.formatFuncCreator(1000, [' pps', ' Kpps', ' Mpps', ' Gpps', ' Tpps', ' Ppps', ' Epps', ' Zpps', ' Ypps']); kbn.valueFormats.Bps = kbn.formatFuncCreator(1000, [' Bps', ' KBps', ' MBps', ' GBps', ' TBps', ' PBps', ' EBps', ' ZBps', ' YBps']); - kbn.valueFormats.short = kbn.formatFuncCreator(1000, ['', ' K', ' Mil', ' Bil', ' Tri', ' Qaudr', ' Quint', ' Sext', ' Sept']); + kbn.valueFormats.short = kbn.formatFuncCreator(1000, ['', ' K', ' Mil', ' Bil', ' Tri', ' Quadr', ' Quint', ' Sext', ' Sept']); kbn.valueFormats.joule = kbn.formatFuncCreator(1000, [' J', ' kJ', ' MJ', ' GJ', ' TJ', ' PJ', ' EJ', ' ZJ', ' YJ']); + kbn.valueFormats.amp = kbn.formatFuncCreator(1000, [' A', ' kA', ' MA', ' GA', ' TA', ' PA', ' EA', ' ZA', ' YA']); + kbn.valueFormats.volt = kbn.formatFuncCreator(1000, [' V', ' kV', ' MV', ' GV', ' TV', ' PV', ' EV', ' ZV', ' YV']); + kbn.valueFormats.hertz = kbn.formatFuncCreator(1000, [' Hz', ' kHz', ' MHz', ' GHz', ' THz', ' PHz', ' EHz', ' ZHz', ' YHz']); kbn.valueFormats.watt = kbn.formatFuncCreator(1000, [' W', ' kW', ' MW', ' GW', ' TW', ' PW', ' EW', ' ZW', ' YW']); kbn.valueFormats.kwatt = kbn.formatFuncCreator(1000, [' kW', ' MW', ' GW', ' TW', ' PW', ' EW', ' ZW', ' YW']); kbn.valueFormats.watth = kbn.formatFuncCreator(1000, [' Wh', ' kWh', ' MWh', ' GWh', ' TWh', ' PWh', ' EWh', ' ZWh', ' YWh']); @@ -389,12 +399,20 @@ function($, _, moment) { kbn.valueFormats.celsius = function(value, decimals) { return kbn.toFixed(value, decimals) + ' °C'; }; kbn.valueFormats.farenheit = function(value, decimals) { return kbn.toFixed(value, decimals) + ' °F'; }; kbn.valueFormats.humidity = function(value, decimals) { return kbn.toFixed(value, decimals) + ' %H'; }; + kbn.valueFormats.pressurembar = function(value, decimals) { return kbn.toFixed(value, decimals) + ' mbar'; }; + kbn.valueFormats.pressurehpa = function(value, decimals) { return kbn.toFixed(value, decimals) + ' hPa'; }; kbn.valueFormats.ppm = function(value, decimals) { return kbn.toFixed(value, decimals) + ' ppm'; }; kbn.valueFormats.velocityms = function(value, decimals) { return kbn.toFixed(value, decimals) + ' m/s'; }; kbn.valueFormats.velocitykmh = function(value, decimals) { return kbn.toFixed(value, decimals) + ' km/h'; }; kbn.valueFormats.velocitymph = function(value, decimals) { return kbn.toFixed(value, decimals) + ' mph'; }; kbn.valueFormats.velocityknot = function(value, decimals) { return kbn.toFixed(value, decimals) + ' kn'; }; + kbn.roundValue = function (num, decimals) { + if (num === null) { return null; } + var n = Math.pow(10, decimals); + return Math.round((n * num).toFixed(decimals)) / n; + }; + kbn.toFixedScaled = function(value, decimals, scaledDecimals, additionalDecimals, ext) { if (scaledDecimals === null) { return kbn.toFixed(value, decimals) + ext; @@ -525,6 +543,7 @@ function($, _, moment) { {text: 'short', value: 'short'}, {text: 'percent', value: 'percent'}, {text: 'ppm', value: 'ppm'}, + {text: 'dB', value: 'dB'}, ] }, { @@ -534,6 +553,7 @@ function($, _, moment) { {text: 'microseconds (µs)', value: 'µs'}, {text: 'milliseconds (ms)', value: 'ms'}, {text: 'seconds (s)', value: 's'}, + {text: 'Hertz (1/s)', value: 'hertz'}, ] }, { @@ -543,11 +563,13 @@ function($, _, moment) { {text: 'bytes', value: 'bytes'}, {text: 'kilobytes', value: 'kbytes'}, {text: 'megabytes', value: 'mbytes'}, + {text: 'gigabytes', value: 'gbytes'}, ] }, { text: 'data rate', submenu: [ + {text: 'packets/sec', value: 'pps'}, {text: 'bits/sec', value: 'bps'}, {text: 'bytes/sec', value: 'Bps'}, ] @@ -561,6 +583,8 @@ function($, _, moment) { {text: 'kilowatt-hour (kWh)', value: 'kwatth'}, {text: 'joule (J)', value: 'joule'}, {text: 'electron volt (eV)', value: 'ev'}, + {text: 'Ampere (A)', value: 'amp'}, + {text: 'Volt (V)', value: 'volt'}, ] }, { @@ -569,6 +593,8 @@ function($, _, moment) { {text: 'Celcius (°C)', value: 'celsius' }, {text: 'Farenheit (°F)', value: 'farenheit'}, {text: 'Humidity (%H)', value: 'humidity' }, + {text: 'Pressure (mbar)', value: 'pressurembar' }, + {text: 'Pressure (hPa)', value: 'pressurehpa' }, ] }, { diff --git a/public/app/components/panelmeta.js b/public/app/components/panelmeta.js index 4ee4a9b9b55..7eee8fa970f 100644 --- a/public/app/components/panelmeta.js +++ b/public/app/components/panelmeta.js @@ -13,12 +13,12 @@ function () { this.extendedMenu = []; if (options.fullscreen) { - this.addMenuItem('view', 'icon-eye-open', 'toggleFullscreen(false); dismiss();'); + this.addMenuItem('View', 'icon-eye-open', 'toggleFullscreen(false); dismiss();'); } - this.addMenuItem('edit', 'icon-cog', 'editPanel(); dismiss();'); - this.addMenuItem('duplicate', 'icon-copy', 'duplicatePanel()'); - this.addMenuItem('share', 'icon-share', 'sharePanel(); dismiss();'); + this.addMenuItem('Edit', 'icon-cog', 'editPanel(); dismiss();', 'Editor'); + this.addMenuItem('Duplicate', 'icon-copy', 'duplicatePanel()', 'Editor'); + this.addMenuItem('Share', 'icon-share', 'sharePanel(); dismiss();'); this.addEditorTab('General', 'app/partials/panelgeneral.html'); @@ -29,12 +29,12 @@ function () { this.addExtendedMenuItem('Panel JSON', '', 'editPanelJson(); dismiss();'); } - PanelMeta.prototype.addMenuItem = function(text, icon, click) { - this.menu.push({text: text, icon: icon, click: click}); + PanelMeta.prototype.addMenuItem = function(text, icon, click, role) { + this.menu.push({text: text, icon: icon, click: click, role: role}); }; - PanelMeta.prototype.addExtendedMenuItem = function(text, icon, click) { - this.extendedMenu.push({text: text, icon: icon, click: click}); + PanelMeta.prototype.addExtendedMenuItem = function(text, icon, click, role) { + this.extendedMenu.push({text: text, icon: icon, click: click, role: role}); }; PanelMeta.prototype.addEditorTab = function(title, src) { diff --git a/public/app/components/require.config.js b/public/app/components/require.config.js index c613d1cc705..12a72dd42e3 100644 --- a/public/app/components/require.config.js +++ b/public/app/components/require.config.js @@ -1,4 +1,3 @@ - require.config({ urlArgs: 'bust=' + (new Date().getTime()), baseUrl: 'public/app', @@ -9,19 +8,18 @@ require.config({ kbn: 'components/kbn', store: 'components/store', - css: '../vendor/require/css', - text: '../vendor/require/text', + text: '../vendor/requirejs-text/text', moment: '../vendor/moment', filesaver: '../vendor/filesaver', ZeroClipboard: '../vendor/ZeroClipboard', angular: '../vendor/angular/angular', - 'angular-route': '../vendor/angular/angular-route', - 'angular-sanitize': '../vendor/angular/angular-sanitize', - 'angular-dragdrop': '../vendor/angular/angular-dragdrop', - 'angular-strap': '../vendor/angular/angular-strap', - timepicker: '../vendor/angular/timepicker', - datepicker: '../vendor/angular/datepicker', - bindonce: '../vendor/angular/bindonce', + 'angular-route': '../vendor/angular-route/angular-route', + 'angular-sanitize': '../vendor/angular-sanitize/angular-sanitize', + 'angular-dragdrop': '../vendor/angular-native-dragdrop/draganddrop', + 'angular-strap': '../vendor/angular-other/angular-strap', + timepicker: '../vendor/angular-other/timepicker', + datepicker: '../vendor/angular-other/datepicker', + bindonce: '../vendor/angular-bindonce/bindonce', crypto: '../vendor/crypto.min', spectrum: '../vendor/spectrum', @@ -29,19 +27,19 @@ require.config({ 'lodash-src': '../vendor/lodash', bootstrap: '../vendor/bootstrap/bootstrap', - jquery: '../vendor/jquery/jquery-2.1.1.min', + jquery: '../vendor/jquery/dist/jquery', 'extend-jquery': 'components/extend-jquery', - 'jquery.flot': '../vendor/jquery/jquery.flot', - 'jquery.flot.pie': '../vendor/jquery/jquery.flot.pie', - 'jquery.flot.events': '../vendor/jquery/jquery.flot.events', - 'jquery.flot.selection': '../vendor/jquery/jquery.flot.selection', - 'jquery.flot.stack': '../vendor/jquery/jquery.flot.stack', - 'jquery.flot.stackpercent':'../vendor/jquery/jquery.flot.stackpercent', - 'jquery.flot.time': '../vendor/jquery/jquery.flot.time', - 'jquery.flot.crosshair': '../vendor/jquery/jquery.flot.crosshair', - 'jquery.flot.fillbelow': '../vendor/jquery/jquery.flot.fillbelow', + 'jquery.flot': '../vendor/flot/jquery.flot', + 'jquery.flot.pie': '../vendor/flot/jquery.flot.pie', + 'jquery.flot.events': '../vendor/flot/jquery.flot.events', + 'jquery.flot.selection': '../vendor/flot/jquery.flot.selection', + 'jquery.flot.stack': '../vendor/flot/jquery.flot.stack', + 'jquery.flot.stackpercent':'../vendor/flot/jquery.flot.stackpercent', + 'jquery.flot.time': '../vendor/flot/jquery.flot.time', + 'jquery.flot.crosshair': '../vendor/flot/jquery.flot.crosshair', + 'jquery.flot.fillbelow': '../vendor/flot/jquery.flot.fillbelow', modernizr: '../vendor/modernizr-2.6.1', @@ -101,5 +99,4 @@ require.config({ 'bootstrap-tagsinput': ['jquery'], }, - waitSeconds: 60, }); diff --git a/public/app/components/timeSeries.js b/public/app/components/timeSeries.js index c356ddea63b..74194e68ff9 100644 --- a/public/app/components/timeSeries.js +++ b/public/app/components/timeSeries.js @@ -13,6 +13,7 @@ function (_, kbn) { this.color = opts.color; this.valueFormater = kbn.valueFormats.none; this.stats = {}; + this.legend = true; } function matchSeriesOverride(aliasOrRegex, seriesAlias) { @@ -53,6 +54,9 @@ function (_, kbn) { if (override.steppedLine !== void 0) { this.lines.steps = override.steppedLine; } if (override.zindex !== void 0) { this.zindex = override.zindex; } if (override.fillBelowTo !== void 0) { this.fillBelowTo = override.fillBelowTo; } + if (override.color !== void 0) { this.color = override.color; } + if (override.transform !== void 0) { this.transform = override.transform; } + if (override.legend !== void 0) { this.legend = override.legend; } if (override.yaxis !== void 0) { this.yaxis = override.yaxis; diff --git a/public/app/controllers/all.js b/public/app/controllers/all.js index f735963e886..99b9a496484 100644 --- a/public/app/controllers/all.js +++ b/public/app/controllers/all.js @@ -6,6 +6,7 @@ define([ './inspectCtrl', './jsonEditorCtrl', './loginCtrl', + './resetPasswordCtrl', './sidemenuCtrl', './errorCtrl', ], function () {}); diff --git a/public/app/controllers/grafanaCtrl.js b/public/app/controllers/grafanaCtrl.js index 08ae1b9c1a8..16e505a68dc 100644 --- a/public/app/controllers/grafanaCtrl.js +++ b/public/app/controllers/grafanaCtrl.js @@ -83,6 +83,26 @@ function (angular, config, _, $, store) { }, function() { }); + $rootScope.performance.panels = []; + + $scope.$on('refresh', function() { + if ($rootScope.performance.panels.length > 0) { + var totalRender = 0; + var totalQuery = 0; + + _.each($rootScope.performance.panels, function(panelTiming) { + totalRender += panelTiming.render; + totalQuery += panelTiming.query; + }); + + console.log('total query: ' + totalQuery); + console.log('total render: ' + totalRender); + console.log('avg render: ' + totalRender / $rootScope.performance.panels.length); + } + + $rootScope.performance.panels = []; + }); + $scope.onAppEvent('dashboard-loaded', function() { count = 0; diff --git a/public/app/controllers/loginCtrl.js b/public/app/controllers/loginCtrl.js index 5de773842f8..40e8009b399 100644 --- a/public/app/controllers/loginCtrl.js +++ b/public/app/controllers/loginCtrl.js @@ -7,27 +7,31 @@ function (angular, config) { var module = angular.module('grafana.controllers'); - module.controller('LoginCtrl', function($scope, backendSrv, contextSrv) { + module.controller('LoginCtrl', function($scope, backendSrv, contextSrv, $location) { $scope.formModel = { user: '', email: '', password: '', }; - contextSrv.setSideMenuState(false); + contextSrv.sidemenu = false; $scope.googleAuthEnabled = config.googleAuthEnabled; $scope.githubAuthEnabled = config.githubAuthEnabled; $scope.disableUserSignUp = config.disableUserSignUp; $scope.loginMode = true; - $scope.submitBtnClass = 'btn-inverse'; $scope.submitBtnText = 'Log in'; - $scope.strengthClass = ''; $scope.init = function() { $scope.$watch("loginMode", $scope.loginModeChanged); - $scope.passwordChanged(); + + var params = $location.search(); + if (params.failedMsg) { + $scope.appEvent('alert-warning', ['Login Failed', params.failedMsg]); + delete params.failedMsg; + $location.search(params); + } }; // build info view model @@ -49,27 +53,6 @@ function (angular, config) { $scope.submitBtnText = newValue ? 'Log in' : 'Sign up'; }; - $scope.passwordChanged = function(newValue) { - if (!newValue) { - $scope.strengthText = ""; - $scope.strengthClass = "hidden"; - return; - } - if (newValue.length < 4) { - $scope.strengthText = "strength: weak sauce."; - $scope.strengthClass = "password-strength-bad"; - return; - } - if (newValue.length <= 6) { - $scope.strengthText = "strength: you can do better."; - $scope.strengthClass = "password-strength-ok"; - return; - } - - $scope.strengthText = "strength: strong like a bull."; - $scope.strengthClass = "password-strength-good"; - }; - $scope.signUp = function() { if (!$scope.loginForm.$valid) { return; diff --git a/public/app/controllers/resetPasswordCtrl.js b/public/app/controllers/resetPasswordCtrl.js new file mode 100644 index 00000000000..ed693f0d45a --- /dev/null +++ b/public/app/controllers/resetPasswordCtrl.js @@ -0,0 +1,45 @@ +define([ + 'angular', +], +function (angular) { + 'use strict'; + + var module = angular.module('grafana.controllers'); + + module.controller('ResetPasswordCtrl', function($scope, contextSrv, backendSrv, $location) { + + contextSrv.sidemenu = false; + $scope.formModel = {}; + $scope.mode = 'send'; + + var params = $location.search(); + if (params.code) { + $scope.mode = 'reset'; + $scope.formModel.code = params.code; + } + + $scope.sendResetEmail = function() { + if (!$scope.sendResetForm.$valid) { + return; + } + backendSrv.post('/api/user/password/send-reset-email', $scope.formModel).then(function() { + $scope.mode = 'email-sent'; + }); + }; + + $scope.submitReset = function() { + if (!$scope.resetForm.$valid) { return; } + + if ($scope.formModel.newPassword !== $scope.formModel.confirmPassword) { + $scope.appEvent('alert-warning', ['New passwords do not match', '']); + return; + } + + backendSrv.post('/api/user/password/reset', $scope.formModel).then(function() { + $location.path('login'); + }); + }; + + }); + +}); diff --git a/public/app/controllers/search.js b/public/app/controllers/search.js index ff171c953a2..a762af0f887 100644 --- a/public/app/controllers/search.js +++ b/public/app/controllers/search.js @@ -13,8 +13,8 @@ function (angular, _, config) { $scope.init = function() { $scope.giveSearchFocus = 0; $scope.selectedIndex = -1; - $scope.results = {dashboards: [], tags: [], metrics: []}; - $scope.query = { query: '', tag: '', starred: false }; + $scope.results = []; + $scope.query = { query: '', tag: [], starred: false }; $scope.currentSearchId = 0; if ($scope.dashboardViewState.fullscreen) { @@ -26,7 +26,6 @@ function (angular, _, config) { $scope.query.query = ''; $scope.search(); }, 100); - }; $scope.keyDown = function (evt) { @@ -40,15 +39,15 @@ function (angular, _, config) { $scope.moveSelection(-1); } if (evt.keyCode === 13) { - if ($scope.query.tagcloud) { - var tag = $scope.results.tags[$scope.selectedIndex]; + if ($scope.tagMode) { + var tag = $scope.results[$scope.selectedIndex]; if (tag) { $scope.filterByTag(tag.term); } return; } - var selectedDash = $scope.results.dashboards[$scope.selectedIndex]; + var selectedDash = $scope.results[$scope.selectedIndex]; if (selectedDash) { $location.search({}); $location.path(selectedDash.url); @@ -57,37 +56,37 @@ function (angular, _, config) { }; $scope.moveSelection = function(direction) { - $scope.selectedIndex = Math.max(Math.min($scope.selectedIndex + direction, $scope.resultCount - 1), 0); + var max = ($scope.results || []).length; + var newIndex = $scope.selectedIndex + direction; + $scope.selectedIndex = ((newIndex %= max) < 0) ? newIndex + max : newIndex; }; $scope.searchDashboards = function() { + $scope.tagsMode = false; $scope.currentSearchId = $scope.currentSearchId + 1; var localSearchId = $scope.currentSearchId; return backendSrv.search($scope.query).then(function(results) { if (localSearchId < $scope.currentSearchId) { return; } - $scope.resultCount = results.tagsOnly ? results.tags.length : results.dashboards.length; - $scope.results.tags = results.tags; - $scope.results.dashboards = _.map(results.dashboards, function(dash) { - dash.url = 'dashboard/db/' + dash.slug; + $scope.results = _.map(results, function(dash) { + dash.url = 'dashboard/' + dash.uri; return dash; }); if ($scope.queryHasNoFilters()) { - $scope.results.dashboards.unshift({ title: 'Home', url: config.appSubUrl + '/', isHome: true }); + $scope.results.unshift({ title: 'Home', url: config.appSubUrl + '/', type: 'dash-home' }); } }); }; $scope.queryHasNoFilters = function() { var query = $scope.query; - return query.query === '' && query.starred === false && query.tag === ''; + return query.query === '' && query.starred === false && query.tag.length === 0; }; $scope.filterByTag = function(tag, evt) { - $scope.query.tag = tag; - $scope.query.tagcloud = false; + $scope.query.tag.push(tag); $scope.search(); $scope.giveSearchFocus = $scope.giveSearchFocus + 1; if (evt) { @@ -96,10 +95,20 @@ function (angular, _, config) { } }; - $scope.showTags = function() { - $scope.query.tagcloud = !$scope.query.tagcloud; - $scope.giveSearchFocus = $scope.giveSearchFocus + 1; + $scope.removeTag = function(tag, evt) { + $scope.query.tag = _.without($scope.query.tag, tag); $scope.search(); + $scope.giveSearchFocus = $scope.giveSearchFocus + 1; + evt.stopPropagation(); + evt.preventDefault(); + }; + + $scope.getTags = function() { + return backendSrv.get('/api/dashboards/tags').then(function(results) { + $scope.tagsMode = true; + $scope.results = results; + $scope.giveSearchFocus = $scope.giveSearchFocus + 1; + }); }; $scope.showStarred = function() { @@ -114,77 +123,10 @@ function (angular, _, config) { $scope.searchDashboards(); }; - $scope.addMetricToCurrentDashboard = function (metricId) { - $scope.dashboard.rows.push({ - title: '', - height: '250px', - editable: true, - panels: [ - { - type: 'graphite', - title: 'test', - span: 12, - targets: [{ target: metricId }] - } - ] - }); - }; - - $scope.toggleImport = function () { - $scope.showImport = !$scope.showImport; - }; - $scope.newDashboard = function() { $location.url('dashboard/new'); }; }); - module.directive('xngFocus', function() { - return function(scope, element, attrs) { - element.click(function(e) { - e.stopPropagation(); - }); - - scope.$watch(attrs.xngFocus,function (newValue) { - if (!newValue) { - return; - } - setTimeout(function() { - element.focus(); - var pos = element.val().length * 2; - element[0].setSelectionRange(pos, pos); - }, 200); - },true); - }; - }); - - module.directive('tagColorFromName', function() { - - function djb2(str) { - var hash = 5381; - for (var i = 0; i < str.length; i++) { - hash = ((hash << 5) + hash) + str.charCodeAt(i); /* hash * 33 + c */ - } - return hash; - } - - return { - scope: { tag: "=" }, - link: function (scope, element) { - var name = scope.tag; - var hash = djb2(name.toLowerCase()); - var colors = [ - "#E24D42","#1F78C1","#BA43A9","#705DA0","#466803", - "#508642","#447EBC","#C15C17","#890F02","#757575", - "#0A437C","#6D1F62","#584477","#629E51","#2F4F4F", - "#BF1B00","#806EB7","#8a2eb8", "#699e00","#000000", - "#3F6833","#2F575E","#99440A","#E0752D","#0E4AB4", - "#58140C","#052B51","#511749","#3F2B5B", - ]; - var color = colors[Math.abs(hash % colors.length)]; - element.css("background-color", color); - } - }; - }); }); diff --git a/public/app/controllers/sidemenuCtrl.js b/public/app/controllers/sidemenuCtrl.js index f5a9197caac..b7ba32f0d35 100644 --- a/public/app/controllers/sidemenuCtrl.js +++ b/public/app/controllers/sidemenuCtrl.js @@ -55,7 +55,7 @@ function (angular, _, $, config) { backendSrv.get('/api/user/orgs').then(function(orgs) { _.each(orgs, function(org) { - if (org.isUsing) { + if (org.orgId === contextSrv.user.orgId) { return; } @@ -68,11 +68,13 @@ function (angular, _, $, config) { }); }); - $scope.orgMenu.push({ - text: "New Organization", - icon: "fa fa-fw fa-plus", - href: $scope.getUrl('/org/new') - }); + if (config.allowOrgCreate) { + $scope.orgMenu.push({ + text: "New Organization", + icon: "fa fa-fw fa-plus", + href: $scope.getUrl('/org/new') + }); + } }); }; diff --git a/public/app/directives/all.js b/public/app/directives/all.js index 6190ef89099..13a8accffbd 100644 --- a/public/app/directives/all.js +++ b/public/app/directives/all.js @@ -5,15 +5,18 @@ define([ './ngBlur', './dashEditLink', './ngModelOnBlur', - './tip', + './misc', './confirmClick', './configModal', './spectrumPicker', - './bootstrap-tagsinput', + './tags', './bodyClass', - './templateParamSelector', - './graphiteSegment', + './valueSelectDropdown', + './metric.segment', './grafanaVersionCheck', './dropdown.typeahead', './topnav', + './giveFocus', + './annotationTooltip', + './passwordStrenght', ], function () {}); diff --git a/public/app/directives/annotationTooltip.js b/public/app/directives/annotationTooltip.js new file mode 100644 index 00000000000..25059d08274 --- /dev/null +++ b/public/app/directives/annotationTooltip.js @@ -0,0 +1,49 @@ +define([ + 'angular', + 'jquery', + 'lodash' +], +function (angular, $, _) { + 'use strict'; + + angular + .module('grafana.directives') + .directive('annotationTooltip', function($sanitize, dashboardSrv, $compile) { + return { + link: function (scope, element) { + var event = scope.event; + var title = $sanitize(event.title); + var dashboard = dashboardSrv.getCurrent(); + var time = '' + dashboard.formatDate(event.min) + ''; + + var tooltip = '
    ' + title + ' ' + time + '
    ' ; + + if (event.text) { + var text = $sanitize(event.text); + tooltip += text.replace(/\n/g, '
    ') + '
    '; + } + + var tags = event.tags; + if (_.isString(event.tags)) { + tags = event.tags.split(','); + if (tags.length === 1) { + tags = event.tags.split(' '); + } + } + + if (tags && tags.length) { + scope.tags = tags; + tooltip += '{{tag}}
    '; + } + + tooltip += "
    "; + + var $tooltip = $(tooltip); + $tooltip.appendTo(element); + + $compile(element.contents())(scope); + } + }; + }); + +}); diff --git a/public/app/directives/bodyClass.js b/public/app/directives/bodyClass.js index d0274aca6b2..495390a59e8 100644 --- a/public/app/directives/bodyClass.js +++ b/public/app/directives/bodyClass.js @@ -16,7 +16,7 @@ function (angular, _, $) { // tooltip removal fix $scope.$on("$routeChangeSuccess", function() { - $("#tooltip").remove(); + $("#tooltip, .tooltip").remove(); }); $scope.$watch('submenuEnabled', function() { diff --git a/public/app/directives/bootstrap-tagsinput.js b/public/app/directives/bootstrap-tagsinput.js deleted file mode 100644 index a8b7eb6a7ad..00000000000 --- a/public/app/directives/bootstrap-tagsinput.js +++ /dev/null @@ -1,134 +0,0 @@ -define([ - 'angular', - 'jquery', - 'bootstrap-tagsinput' -], -function (angular, $) { - 'use strict'; - - angular - .module('grafana.directives') - .directive('bootstrapTagsinput', function() { - - function getItemProperty(scope, property) { - if (!property) { - return undefined; - } - - if (angular.isFunction(scope.$parent[property])) { - return scope.$parent[property]; - } - - return function(item) { - return item[property]; - }; - } - - return { - restrict: 'EA', - scope: { - model: '=ngModel' - }, - template: '', - replace: false, - link: function(scope, element, attrs) { - - if (!angular.isArray(scope.model)) { - scope.model = []; - } - - var select = $('select', element); - - if (attrs.placeholder) { - select.attr('placeholder', attrs.placeholder); - } - - select.tagsinput({ - typeahead : { - source : angular.isFunction(scope.$parent[attrs.typeaheadSource]) ? scope.$parent[attrs.typeaheadSource] : null - }, - itemValue: getItemProperty(scope, attrs.itemvalue), - itemText : getItemProperty(scope, attrs.itemtext), - tagClass : angular.isFunction(scope.$parent[attrs.tagclass]) ? - scope.$parent[attrs.tagclass] : function() { return attrs.tagclass; } - }); - - select.on('itemAdded', function(event) { - if (scope.model.indexOf(event.item) === -1) { - scope.model.push(event.item); - } - }); - - select.on('itemRemoved', function(event) { - var idx = scope.model.indexOf(event.item); - if (idx !== -1) { - scope.model.splice(idx, 1); - } - }); - - scope.$watch("model", function() { - if (!angular.isArray(scope.model)) { - scope.model = []; - } - - select.tagsinput('removeAll'); - - for (var i = 0; i < scope.model.length; i++) { - select.tagsinput('add', scope.model[i]); - } - - }, true); - - } - }; - }); - - angular - .module('grafana.directives') - .directive('gfDropdown', function ($parse, $compile, $timeout) { - - function buildTemplate(items, placement) { - var upclass = placement === 'top' ? 'dropup' : ''; - var ul = [ - '' - ]; - - angular.forEach(items, function (item, index) { - if (item.divider) { - return ul.splice(index + 1, 0, '
  • '); - } - - var li = '' + - '' + (item.text || '') + ''; - - if (item.submenu && item.submenu.length) { - li += buildTemplate(item.submenu).join('\n'); - } - - li += ''; - ul.splice(index + 1, 0, li); - }); - return ul; - } - - return { - restrict: 'EA', - scope: true, - link: function postLink(scope, iElement, iAttrs) { - var getter = $parse(iAttrs.gfDropdown), items = getter(scope); - $timeout(function () { - var placement = iElement.data('placement'); - var dropdown = angular.element(buildTemplate(items, placement).join('')); - dropdown.insertAfter(iElement); - $compile(iElement.next('ul.dropdown-menu'))(scope); - }); - - iElement.addClass('dropdown-toggle').attr('data-toggle', 'dropdown'); - } - }; - }); -}); diff --git a/public/app/directives/dashEditLink.js b/public/app/directives/dashEditLink.js index 67e2856e1fe..6145fb46b57 100644 --- a/public/app/directives/dashEditLink.js +++ b/public/app/directives/dashEditLink.js @@ -6,9 +6,9 @@ function (angular, $) { 'use strict'; var editViewMap = { - 'settings': { src: 'app/partials/dasheditor.html', title: "Settings" }, + 'settings': { src: 'app/features/dashboard/partials/settings.html', title: "Settings" }, 'annotations': { src: 'app/features/annotations/partials/editor.html', title: "Annotations" }, - 'templating': { src: 'app/partials/templating_editor.html', title: "Templating" } + 'templating': { src: 'app/features/templating/partials/editor.html', title: "Templating" } }; angular diff --git a/public/app/directives/dashUpload.js b/public/app/directives/dashUpload.js index d9f8e3e4ae9..89d52c75916 100644 --- a/public/app/directives/dashUpload.js +++ b/public/app/directives/dashUpload.js @@ -25,7 +25,7 @@ function (angular, kbn) { } var title = kbn.slugifyForUrl(window.grafanaImportDashboard.title); window.grafanaImportDashboard.id = null; - $location.path('/dashboard/import/' + title); + $location.path('/dashboard-import/' + title); }); }; }; diff --git a/public/app/directives/giveFocus.js b/public/app/directives/giveFocus.js new file mode 100644 index 00000000000..ef395d27fbd --- /dev/null +++ b/public/app/directives/giveFocus.js @@ -0,0 +1,26 @@ +define([ + 'angular' +], +function (angular) { + 'use strict'; + + angular.module('grafana.directives').directive('giveFocus', function() { + return function(scope, element, attrs) { + element.click(function(e) { + e.stopPropagation(); + }); + + scope.$watch(attrs.giveFocus,function (newValue) { + if (!newValue) { + return; + } + setTimeout(function() { + element.focus(); + var pos = element.val().length * 2; + element[0].setSelectionRange(pos, pos); + }, 200); + },true); + }; + }); + +}); diff --git a/public/app/directives/graphiteSegment.js b/public/app/directives/metric.segment.js similarity index 79% rename from public/app/directives/graphiteSegment.js rename to public/app/directives/metric.segment.js index c8ad131e6c7..4202cfdc332 100644 --- a/public/app/directives/graphiteSegment.js +++ b/public/app/directives/metric.segment.js @@ -9,14 +9,21 @@ function (angular, app, _, $) { angular .module('grafana.directives') - .directive('graphiteSegment', function($compile, $sce) { + .directive('metricSegment', function($compile, $sce) { var inputTemplate = ''; - var buttonTemplate = ''; + var buttonTemplate = ''; return { + scope: { + segment: "=", + getAltSegments: "&", + onValueChanged: "&" + }, + link: function($scope, elem) { var $input = $(inputTemplate); var $button = $(buttonTemplate); @@ -46,7 +53,7 @@ function (angular, app, _, $) { segment.expandable = true; segment.fake = false; } - $scope.segmentValueChanged(segment, $scope.$index); + $scope.onValueChanged(); }); }; @@ -61,7 +68,7 @@ function (angular, app, _, $) { else { // need to have long delay because the blur // happens long before the click event on the typeahead options - cancelBlur = setTimeout($scope.switchToLink, 350); + cancelBlur = setTimeout($scope.switchToLink, 50); } }; @@ -69,7 +76,8 @@ function (angular, app, _, $) { if (options) { return options; } $scope.$apply(function() { - $scope.getAltSegments($scope.$index).then(function() { + $scope.getAltSegments().then(function(altSegments) { + $scope.altSegments = altSegments; options = _.map($scope.altSegments, function(alt) { return alt.value; }); // add custom values @@ -95,8 +103,19 @@ function (angular, app, _, $) { return value; }; + $scope.matcher = function(item) { + var str = this.query; + if (str[0] === '/') { str = str.substring(1); } + if (str[str.length - 1] === '/') { str = str.substring(0, str.length-1); } + try { + return item.toLowerCase().match(str); + } catch(e) { + return false; + } + }; + $input.attr('data-provide', 'typeahead'); - $input.typeahead({ source: $scope.source, minLength: 0, items: 10000, updater: $scope.updater }); + $input.typeahead({ source: $scope.source, minLength: 0, items: 10000, updater: $scope.updater, matcher: $scope.matcher }); var typeahead = $input.data('typeahead'); typeahead.lookup = function () { diff --git a/public/app/directives/tip.js b/public/app/directives/misc.js similarity index 61% rename from public/app/directives/tip.js rename to public/app/directives/misc.js index edf57d47828..11c1334d553 100644 --- a/public/app/directives/tip.js +++ b/public/app/directives/misc.js @@ -72,15 +72,59 @@ function (angular, kbn) { ' ng-checked="' + attrs.model + '">' + ' '; - if (attrs.position === "front") { - template = label + template; - } else { - template = template + label; - } - + template = label + template; elem.replaceWith($compile(angular.element(template))(scope)); } }; }); + angular + .module('grafana.directives') + .directive('gfDropdown', function ($parse, $compile, $timeout) { + + function buildTemplate(items, placement) { + var upclass = placement === 'top' ? 'dropup' : ''; + var ul = [ + '' + ]; + + angular.forEach(items, function (item, index) { + if (item.divider) { + return ul.splice(index + 1, 0, '
  • '); + } + + var li = '' + + '' + (item.text || '') + ''; + + if (item.submenu && item.submenu.length) { + li += buildTemplate(item.submenu).join('\n'); + } + + li += ''; + ul.splice(index + 1, 0, li); + }); + return ul; + } + + return { + restrict: 'EA', + scope: true, + link: function postLink(scope, iElement, iAttrs) { + var getter = $parse(iAttrs.gfDropdown), items = getter(scope); + $timeout(function () { + var placement = iElement.data('placement'); + var dropdown = angular.element(buildTemplate(items, placement).join('')); + dropdown.insertAfter(iElement); + $compile(iElement.next('ul.dropdown-menu'))(scope); + }); + + iElement.addClass('dropdown-toggle').attr('data-toggle', 'dropdown'); + } + }; + }); + }); diff --git a/public/app/directives/passwordStrenght.js b/public/app/directives/passwordStrenght.js new file mode 100644 index 00000000000..f75a8fe8854 --- /dev/null +++ b/public/app/directives/passwordStrenght.js @@ -0,0 +1,47 @@ +define([ + 'angular', +], +function (angular) { + 'use strict'; + + angular + .module('grafana.directives') + .directive('passwordStrength', function() { + var template = '
    ' + + '{{strengthText}}' + + '
    '; + return { + template: template, + scope: { + password: "=", + }, + link: function($scope) { + + $scope.strengthClass = ''; + + function passwordChanged(newValue) { + if (!newValue) { + $scope.strengthText = ""; + $scope.strengthClass = "hidden"; + return; + } + if (newValue.length < 4) { + $scope.strengthText = "strength: weak sauce."; + $scope.strengthClass = "password-strength-bad"; + return; + } + if (newValue.length <= 8) { + $scope.strengthText = "strength: you can do better."; + $scope.strengthClass = "password-strength-ok"; + return; + } + + $scope.strengthText = "strength: strong like a bull."; + $scope.strengthClass = "password-strength-good"; + } + + $scope.$watch("password", passwordChanged); + } + }; + }); +}); diff --git a/public/app/directives/tags.js b/public/app/directives/tags.js new file mode 100644 index 00000000000..f408a5e3864 --- /dev/null +++ b/public/app/directives/tags.js @@ -0,0 +1,137 @@ +define([ + 'angular', + 'jquery', + 'bootstrap-tagsinput' +], +function (angular, $) { + 'use strict'; + + function djb2(str) { + var hash = 5381; + for (var i = 0; i < str.length; i++) { + hash = ((hash << 5) + hash) + str.charCodeAt(i); /* hash * 33 + c */ + } + return hash; + } + + function setColor(name, element) { + var hash = djb2(name.toLowerCase()); + var colors = [ + "#E24D42","#1F78C1","#BA43A9","#705DA0","#466803", + "#508642","#447EBC","#C15C17","#890F02","#757575", + "#0A437C","#6D1F62","#584477","#629E51","#2F4F4F", + "#BF1B00","#806EB7","#8a2eb8", "#699e00","#000000", + "#3F6833","#2F575E","#99440A","#E0752D","#0E4AB4", + "#58140C","#052B51","#511749","#3F2B5B", + ]; + var borderColors = [ + "#FF7368","#459EE7","#E069CF","#9683C6","#6C8E29", + "#76AC68","#6AA4E2","#E7823D","#AF3528","#9B9B9B", + "#3069A2","#934588","#7E6A9D","#88C477","#557575", + "#E54126","#A694DD","#B054DE", "#8FC426","#262626", + "#658E59","#557D84","#BF6A30","#FF9B53","#3470DA", + "#7E3A32","#2B5177","#773D6F","#655181", + ]; + var color = colors[Math.abs(hash % colors.length)]; + var borderColor = borderColors[Math.abs(hash % borderColors.length)]; + element.css("background-color", color); + element.css("border-color", borderColor); + } + + angular + .module('grafana.directives') + .directive('tagColorFromName', function() { + return { + scope: { tagColorFromName: "=" }, + link: function (scope, element) { + setColor(scope.tagColorFromName, element); + } + }; + }); + + angular + .module('grafana.directives') + .directive('bootstrapTagsinput', function() { + + function getItemProperty(scope, property) { + if (!property) { + return undefined; + } + + if (angular.isFunction(scope.$parent[property])) { + return scope.$parent[property]; + } + + return function(item) { + return item[property]; + }; + } + + return { + restrict: 'EA', + scope: { + model: '=ngModel', + onTagsUpdated: "&", + }, + template: '', + replace: false, + link: function(scope, element, attrs) { + + if (!angular.isArray(scope.model)) { + scope.model = []; + } + + var select = $('select', element); + + if (attrs.placeholder) { + select.attr('placeholder', attrs.placeholder); + } + + select.tagsinput({ + typeahead: { + source: angular.isFunction(scope.$parent[attrs.typeaheadSource]) ? scope.$parent[attrs.typeaheadSource] : null + }, + itemValue: getItemProperty(scope, attrs.itemvalue), + itemText : getItemProperty(scope, attrs.itemtext), + tagClass : angular.isFunction(scope.$parent[attrs.tagclass]) ? + scope.$parent[attrs.tagclass] : function() { return attrs.tagclass; } + }); + + select.on('itemAdded', function(event) { + if (scope.model.indexOf(event.item) === -1) { + scope.model.push(event.item); + if (scope.onTagsUpdated) { + scope.onTagsUpdated(); + } + } + var tagElement = select.next().children("span").filter(function() { return $(this).text() === event.item; }); + setColor(event.item, tagElement); + }); + + select.on('itemRemoved', function(event) { + var idx = scope.model.indexOf(event.item); + if (idx !== -1) { + scope.model.splice(idx, 1); + if (scope.onTagsUpdated) { + scope.onTagsUpdated(); + } + } + }); + + scope.$watch("model", function() { + if (!angular.isArray(scope.model)) { + scope.model = []; + } + + select.tagsinput('removeAll'); + + for (var i = 0; i < scope.model.length; i++) { + select.tagsinput('add', scope.model[i]); + } + + }, true); + } + }; + }); + +}); diff --git a/public/app/directives/templateParamSelector.js b/public/app/directives/templateParamSelector.js deleted file mode 100644 index d9ad33512b5..00000000000 --- a/public/app/directives/templateParamSelector.js +++ /dev/null @@ -1,87 +0,0 @@ -define([ - 'angular', - 'app', - 'lodash', - 'jquery', -], -function (angular, app, _, $) { - 'use strict'; - - angular - .module('grafana.directives') - .directive('templateParamSelector', function($compile) { - var inputTemplate = ''; - - var buttonTemplate = '{{variable.current.text}} '; - - return { - link: function($scope, elem) { - var $input = $(inputTemplate); - var $button = $(buttonTemplate); - var variable = $scope.variable; - - $input.appendTo(elem); - $button.appendTo(elem); - - function updateVariableValue(value) { - $scope.$apply(function() { - var selected = _.findWhere(variable.options, { text: value }); - if (!selected) { - selected = { text: value, value: value }; - } - $scope.setVariableValue($scope.variable, selected); - }); - } - - $input.attr('data-provide', 'typeahead'); - $input.typeahead({ - minLength: 0, - items: 1000, - updater: function(value) { - $input.val(value); - $input.trigger('blur'); - return value; - } - }); - - var typeahead = $input.data('typeahead'); - typeahead.lookup = function () { - var options = _.map(variable.options, function(option) { return option.text; }); - this.query = this.$element.val() || ''; - return this.process(options); - }; - - $button.click(function() { - $input.css('width', ($button.width() + 16) + 'px'); - - $button.hide(); - $input.show(); - $input.focus(); - - var typeahead = $input.data('typeahead'); - if (typeahead) { - $input.val(''); - typeahead.lookup(); - } - - }); - - $input.blur(function() { - if ($input.val() !== '') { updateVariableValue($input.val()); } - $input.hide(); - $button.show(); - $button.focus(); - }); - - $scope.$on('$destroy', function() { - $button.unbind(); - typeahead.destroy(); - }); - - $compile(elem.contents())($scope); - } - }; - }); -}); diff --git a/public/app/directives/valueSelectDropdown.js b/public/app/directives/valueSelectDropdown.js new file mode 100644 index 00000000000..65b45135fd5 --- /dev/null +++ b/public/app/directives/valueSelectDropdown.js @@ -0,0 +1,282 @@ +define([ + 'angular', + 'app', + 'lodash', + 'jquery', +], +function (angular, app, _) { + 'use strict'; + + angular + .module('grafana.controllers') + .controller('ValueSelectDropdownCtrl', function($q) { + var vm = this; + + vm.show = function() { + vm.oldVariableText = vm.variable.current.text; + vm.highlightIndex = -1; + + vm.options = vm.variable.options; + vm.selectedValues = _.filter(vm.options, {selected: true}); + + vm.tags = _.map(vm.variable.tags, function(value) { + var tag = { text: value, selected: false }; + _.each(vm.variable.current.tags, function(tagObj) { + if (tagObj.text === value) { + tag = tagObj; + } + }); + return tag; + }); + + vm.search = { + query: '', + options: vm.options.slice(0, Math.min(vm.options.length, 1000)) + }; + + vm.dropdownVisible = true; + }; + + vm.updateLinkText = function() { + var current = vm.variable.current; + + if (current.tags && current.tags.length) { + // filer out values that are in selected tags + var selectedAndNotInTag = _.filter(vm.variable.options, function(option) { + if (!option.selected) { return false; } + for (var i = 0; i < current.tags.length; i++) { + var tag = current.tags[i]; + if (_.indexOf(tag.values, option.value) !== -1) { + return false; + } + } + return true; + }); + + // convert values to text + var currentTexts = _.pluck(selectedAndNotInTag, 'text'); + + // join texts + vm.linkText = currentTexts.join(' + '); + if (vm.linkText.length > 0) { + vm.linkText += ' + '; + } + } else { + vm.linkText = vm.variable.current.text; + } + }; + + vm.clearSelections = function() { + _.each(vm.options, function(option) { + option.selected = false; + }); + + vm.selectionsChanged(false); + }; + + vm.selectTag = function(tag) { + tag.selected = !tag.selected; + var tagValuesPromise; + if (!tag.values) { + tagValuesPromise = vm.getValuesForTag({tagKey: tag.text}); + } else { + tagValuesPromise = $q.when(tag.values); + } + + tagValuesPromise.then(function(values) { + tag.values = values; + tag.valuesText = values.join(' + '); + _.each(vm.options, function(option) { + if (_.indexOf(tag.values, option.value) !== -1) { + option.selected = tag.selected; + } + }); + + vm.selectionsChanged(false); + }); + }; + + vm.keyDown = function (evt) { + if (evt.keyCode === 27) { + vm.hide(); + } + if (evt.keyCode === 40) { + vm.moveHighlight(1); + } + if (evt.keyCode === 38) { + vm.moveHighlight(-1); + } + if (evt.keyCode === 13) { + vm.optionSelected(vm.search.options[vm.highlightIndex], {}, true, false); + } + if (evt.keyCode === 32) { + vm.optionSelected(vm.search.options[vm.highlightIndex], {}, false, false); + } + }; + + vm.moveHighlight = function(direction) { + vm.highlightIndex = (vm.highlightIndex + direction) % vm.search.options.length; + }; + + vm.selectValue = function(option, event, commitChange, excludeOthers) { + if (!option) { return; } + + option.selected = !option.selected; + + commitChange = commitChange || false; + excludeOthers = excludeOthers || false; + + var setAllExceptCurrentTo = function(newValue) { + _.each(vm.options, function(other) { + if (option !== other) { other.selected = newValue; } + }); + }; + + // commit action (enter key), should not deselect it + if (commitChange) { + option.selected = true; + } + + if (option.text === 'All' || excludeOthers) { + setAllExceptCurrentTo(false); + commitChange = true; + } + else if (!vm.variable.multi) { + setAllExceptCurrentTo(false); + commitChange = true; + } else if (event.ctrlKey || event.metaKey || event.shiftKey) { + commitChange = true; + setAllExceptCurrentTo(false); + } + + vm.selectionsChanged(commitChange); + }; + + vm.selectionsChanged = function(commitChange) { + vm.selectedValues = _.filter(vm.options, {selected: true}); + + if (vm.selectedValues.length > 1 && vm.selectedValues.length !== vm.options.length) { + if (vm.selectedValues[0].text === 'All') { + vm.selectedValues[0].selected = false; + vm.selectedValues = vm.selectedValues.slice(1, vm.selectedValues.length); + } + } + + // validate selected tags + _.each(vm.tags, function(tag) { + if (tag.selected) { + _.each(tag.values, function(value) { + if (!_.findWhere(vm.selectedValues, {value: value})) { + tag.selected = false; + } + }); + } + }); + + vm.selectedTags = _.filter(vm.tags, {selected: true}); + vm.variable.current.value = _.pluck(vm.selectedValues, 'value'); + vm.variable.current.text = _.pluck(vm.selectedValues, 'text').join(' + '); + vm.variable.current.tags = vm.selectedTags; + + // only single value + if (vm.selectedValues.length === 1) { + vm.variable.current.value = vm.selectedValues[0].value; + } + + if (commitChange) { + vm.commitChanges(); + } + }; + + vm.commitChanges = function() { + // make sure one option is selected + if (vm.selectedValues.length === 0) { + vm.options[0].selected = true; + vm.selectionsChanged(false); + } + + vm.dropdownVisible = false; + vm.updateLinkText(); + + if (vm.variable.current.text !== vm.oldVariableText) { + vm.onUpdated(); + } + }; + + vm.queryChanged = function() { + vm.highlightIndex = -1; + vm.search.options = _.filter(vm.options, function(option) { + return option.text.toLowerCase().indexOf(vm.search.query.toLowerCase()) !== -1; + }); + + vm.search.options = vm.search.options.slice(0, Math.min(vm.search.options.length, 1000)); + }; + + vm.init = function() { + vm.selectedTags = vm.variable.current.tags || []; + vm.updateLinkText(); + }; + + }); + + angular + .module('grafana.directives') + .directive('valueSelectDropdown', function($compile, $window, $timeout, $rootScope) { + + return { + scope: { variable: "=", onUpdated: "&", getValuesForTag: "&" }, + templateUrl: 'app/partials/valueSelectDropdown.html', + controller: 'ValueSelectDropdownCtrl', + controllerAs: 'vm', + bindToController: true, + link: function(scope, elem) { + var bodyEl = angular.element($window.document.body); + var linkEl = elem.find('.variable-value-link'); + var inputEl = elem.find('input'); + + function openDropdown() { + inputEl.css('width', Math.max(linkEl.width(), 30) + 'px'); + + inputEl.show(); + linkEl.hide(); + + inputEl.focus(); + $timeout(function() { bodyEl.on('click', bodyOnClick); }, 0, false); + } + + function switchToLink() { + inputEl.hide(); + linkEl.show(); + bodyEl.off('click', bodyOnClick); + } + + function bodyOnClick (e) { + if (elem.has(e.target).length === 0) { + scope.$apply(function() { + scope.vm.commitChanges(); + }); + } + } + + scope.$watch('vm.dropdownVisible', function(newValue) { + if (newValue) { + openDropdown(); + } else { + switchToLink(); + } + }); + + var cleanUp = $rootScope.$on('template-variable-value-updated', function() { + scope.vm.updateLinkText(); + }); + + scope.$on("$destroy", function() { + cleanUp(); + }); + + scope.vm.init(); + }, + }; + }); + +}); diff --git a/public/app/features/admin/adminEditUserCtrl.js b/public/app/features/admin/adminEditUserCtrl.js index 19deac532ea..d8500a845d3 100644 --- a/public/app/features/admin/adminEditUserCtrl.js +++ b/public/app/features/admin/adminEditUserCtrl.js @@ -1,23 +1,26 @@ define([ 'angular', + 'lodash', ], -function (angular) { +function (angular, _) { 'use strict'; var module = angular.module('grafana.controllers'); module.controller('AdminEditUserCtrl', function($scope, $routeParams, backendSrv, $location) { $scope.user = {}; + $scope.newOrg = { name: '', role: 'Editor' }; $scope.permissions = {}; $scope.init = function() { if ($routeParams.id) { $scope.getUser($routeParams.id); + $scope.getUserOrgs($routeParams.id); } }; $scope.getUser = function(id) { - backendSrv.get('/api/admin/users/' + id).then(function(user) { + backendSrv.get('/api/users/' + id).then(function(user) { $scope.user = user; $scope.user_id = id; $scope.permissions.isGrafanaAdmin = user.isGrafanaAdmin; @@ -49,14 +52,58 @@ function (angular) { }); }; + $scope.getUserOrgs = function(id) { + backendSrv.get('/api/users/' + id + '/orgs').then(function(orgs) { + $scope.orgs = orgs; + }); + }; + $scope.update = function() { if (!$scope.userForm.$valid) { return; } - backendSrv.put('/api/admin/users/' + $scope.user_id + '/details', $scope.user).then(function() { + backendSrv.put('/api/users/' + $scope.user_id, $scope.user).then(function() { $location.path('/admin/users'); }); }; + $scope.updateOrgUser= function(orgUser) { + backendSrv.patch('/api/orgs/' + orgUser.orgId + '/users/' + $scope.user_id, orgUser).then(function() { + }); + }; + + $scope.removeOrgUser = function(orgUser) { + backendSrv.delete('/api/orgs/' + orgUser.orgId + '/users/' + $scope.user_id).then(function() { + $scope.getUserOrgs($scope.user_id); + }); + }; + + $scope.orgsSearchCache = []; + + $scope.searchOrgs = function(queryStr, callback) { + if ($scope.orgsSearchCache.length > 0) { + callback(_.pluck($scope.orgsSearchCache, "name")); + return; + } + + backendSrv.get('/api/orgs', {query: ''}).then(function(result) { + $scope.orgsSearchCache = result; + callback(_.pluck(result, "name")); + }); + }; + + $scope.addOrgUser = function() { + if (!$scope.addOrgForm.$valid) { return; } + + var orgInfo = _.findWhere($scope.orgsSearchCache, {name: $scope.newOrg.name}); + if (!orgInfo) { return; } + + $scope.newOrg.loginOrEmail = $scope.user.login; + + backendSrv.post('/api/orgs/' + orgInfo.id + '/users/', $scope.newOrg).then(function() { + $scope.getUserOrgs($scope.user_id); + }); + }; + $scope.init(); }); diff --git a/public/app/features/admin/adminUsersCtrl.js b/public/app/features/admin/adminUsersCtrl.js index c9be0238fd4..737812c5474 100644 --- a/public/app/features/admin/adminUsersCtrl.js +++ b/public/app/features/admin/adminUsersCtrl.js @@ -13,7 +13,7 @@ function (angular) { }; $scope.getUsers = function() { - backendSrv.get('/api/admin/users').then(function(users) { + backendSrv.get('/api/users').then(function(users) { $scope.users = users; }); }; diff --git a/public/app/features/admin/partials/edit_user.html b/public/app/features/admin/partials/edit_user.html index 9b2a18fd010..a1a4cb989cd 100644 --- a/public/app/features/admin/partials/edit_user.html +++ b/public/app/features/admin/partials/edit_user.html @@ -25,7 +25,7 @@
    -
    +
    • Email @@ -36,7 +36,7 @@
    -
    +
    • Username @@ -80,19 +80,73 @@ Permissions -
      -
        -
      • - Grafana Admin  - - -
      • -
      -
      +
      +
      +
        +
      • + Grafana Admin  + + +
      • +
      +
      +
      +
      + +
      -
      - + +

      + Organizations +

      + +
      +
      +
        +
      • + Add organization +
      • +
      • + +
      • +
      • + Role +
      • +
      • + +
      • +
      • + +
      • +
        +
      +
      +
      + + + + + + + + + + + + +
      NameRole
      + {{org.name}} Current + + + + + + +
    diff --git a/public/app/features/admin/partials/new_user.html b/public/app/features/admin/partials/new_user.html index 48f78fb76b6..73877f9bda4 100644 --- a/public/app/features/admin/partials/new_user.html +++ b/public/app/features/admin/partials/new_user.html @@ -24,7 +24,7 @@
    -
    +
    • Email @@ -35,7 +35,7 @@
    -
    +
    • Username @@ -46,7 +46,7 @@
    -
    +
    • Password diff --git a/public/app/features/admin/partials/users.html b/public/app/features/admin/partials/users.html index 14bcd922b5e..6d4f7a8671c 100644 --- a/public/app/features/admin/partials/users.html +++ b/public/app/features/admin/partials/users.html @@ -1,4 +1,4 @@ - +
    -
    + + diff --git a/public/app/features/dashboard/playlistCtrl.js b/public/app/features/dashboard/playlistCtrl.js index 5320a0b808e..b5d04374e9a 100644 --- a/public/app/features/dashboard/playlistCtrl.js +++ b/public/app/features/dashboard/playlistCtrl.js @@ -25,14 +25,14 @@ function (angular, _, config) { } backendSrv.search(query).then(function(results) { - $scope.searchHits = results.dashboards; + $scope.searchHits = results; $scope.filterHits(); }); }; $scope.filterHits = function() { $scope.filteredHits = _.reject($scope.searchHits, function(dash) { - return _.findWhere($scope.playlist, {slug: dash.slug}); + return _.findWhere($scope.playlist, {uri: dash.uri}); }); }; diff --git a/public/app/features/dashboard/playlistSrv.js b/public/app/features/dashboard/playlistSrv.js index 0711cb7c453..9997581fbc3 100644 --- a/public/app/features/dashboard/playlistSrv.js +++ b/public/app/features/dashboard/playlistSrv.js @@ -18,7 +18,7 @@ function (angular, _, kbn) { angular.element(window).unbind('resize'); var dash = self.dashboards[self.index % self.dashboards.length]; - $location.url('dashboard/db/' + dash.slug); + $location.url('dashboard/' + dash.uri); self.index++; self.cancelPromise = $timeout(self.next, self.interval); diff --git a/public/app/features/dashboard/rowCtrl.js b/public/app/features/dashboard/rowCtrl.js index 1f839bd206a..c63017365bb 100644 --- a/public/app/features/dashboard/rowCtrl.js +++ b/public/app/features/dashboard/rowCtrl.js @@ -22,7 +22,6 @@ function (angular, app, _, config) { $scope.init = function() { $scope.editor = {index: 0}; - $scope.reset_panel(); }; $scope.togglePanelMenu = function(posX) { @@ -64,8 +63,18 @@ function (angular, app, _, config) { }; $scope.add_panel_default = function(type) { - $scope.reset_panel(type); - $scope.add_panel($scope.panel); + var defaultSpan = 12; + var _as = 12 - $scope.dashboard.rowSpan($scope.row); + + var panel = { + title: config.new_panel_title, + error: false, + span: _as < defaultSpan && _as > 0 ? _as : defaultSpan, + editable: true, + type: type + }; + + $scope.add_panel(panel); $timeout(function() { $scope.$broadcast('render'); @@ -105,31 +114,6 @@ function (angular, app, _, config) { }); }; - $scope.reset_panel = function(type) { - var defaultSpan = 12; - var _as = 12 - $scope.dashboard.rowSpan($scope.row); - - $scope.panel = { - title: config.new_panel_title, - error: false, - span: _as < defaultSpan && _as > 0 ? _as : defaultSpan, - editable: true, - type: type - }; - - function fixRowHeight(height) { - if (!height) { - return '200px'; - } - if (!_.isString(height)) { - return height + 'px'; - } - return height; - } - - $scope.row.height = fixRowHeight($scope.row.height); - }; - $scope.init(); }); diff --git a/public/app/features/dashboard/shareModalCtrl.js b/public/app/features/dashboard/shareModalCtrl.js index 68c0bf606a0..55ec0c8a410 100644 --- a/public/app/features/dashboard/shareModalCtrl.js +++ b/public/app/features/dashboard/shareModalCtrl.js @@ -9,7 +9,8 @@ function (angular, _, require, config) { var module = angular.module('grafana.controllers'); - module.controller('ShareModalCtrl', function($scope, $rootScope, $location, $timeout, timeSrv, $element, templateSrv) { + module.controller('ShareModalCtrl', function($scope, $rootScope, $location, $timeout, timeSrv, $element, templateSrv, linkSrv) { + $scope.options = { forCurrent: true, includeTemplateVars: true, theme: 'current' }; $scope.editor = { index: 0 }; @@ -42,19 +43,12 @@ function (angular, _, require, config) { var params = angular.copy($location.search()); - var range = timeSrv.timeRangeForUrl(); - params.from = range.from; - params.to = range.to; + var range = timeSrv.timeRange(); + params.from = range.from.getTime(); + params.to = range.to.getTime(); if ($scope.options.includeTemplateVars) { - _.each(templateSrv.variables, function(variable) { - params['var-' + variable.name] = variable.current.text; - }); - } - else { - _.each(templateSrv.variables, function(variable) { - delete params['var-' + variable.name]; - }); + templateSrv.fillVariableValuesForUrl(params); } if (!$scope.options.forCurrent) { @@ -74,27 +68,14 @@ function (angular, _, require, config) { delete params.fullscreen; } - var paramsArray = []; - _.each(params, function(value, key) { - if (value === null) { return; } - if (value === true) { - paramsArray.push(key); - } else { - key += '=' + encodeURIComponent(value); - paramsArray.push(key); - } - }); - - var queryParams = "?" + paramsArray.join('&'); - $scope.shareUrl = baseUrl + queryParams; + $scope.shareUrl = linkSrv.addParamsToUrl(baseUrl, params); var soloUrl = $scope.shareUrl; - soloUrl = soloUrl.replace('/dashboard/db/', '/dashboard/solo/db/'); - soloUrl = soloUrl.replace('/dashboard/snapshot/', '/dashboard/solo/snapshot/'); + soloUrl = soloUrl.replace('/dashboard/', '/dashboard-solo/'); $scope.iframeHtml = ''; - $scope.imageUrl = soloUrl.replace('/dashboard/', '/render/dashboard/'); + $scope.imageUrl = soloUrl.replace('/dashboard', '/render/dashboard'); $scope.imageUrl += '&width=1000'; $scope.imageUrl += '&height=500'; }; diff --git a/public/app/features/dashboard/submenuCtrl.js b/public/app/features/dashboard/submenuCtrl.js index 456cc1b76c1..b1d0dc3ae32 100644 --- a/public/app/features/dashboard/submenuCtrl.js +++ b/public/app/features/dashboard/submenuCtrl.js @@ -1,24 +1,18 @@ define([ 'angular', - 'lodash' ], -function (angular, _) { +function (angular) { 'use strict'; var module = angular.module('grafana.controllers'); - module.controller('SubmenuCtrl', function($scope, $q, $rootScope, templateValuesSrv) { - var _d = { - enable: true - }; - - _.defaults($scope.pulldown,_d); + module.controller('SubmenuCtrl', function($scope, $q, $rootScope, templateValuesSrv, dynamicDashboardSrv) { $scope.init = function() { $scope.panel = $scope.pulldown; $scope.row = $scope.pulldown; - $scope.variables = $scope.dashboard.templating.list; $scope.annotations = $scope.dashboard.templating.list; + $scope.variables = $scope.dashboard.templating.list; }; $scope.disableAnnotation = function (annotation) { @@ -26,8 +20,16 @@ function (angular, _) { $rootScope.$broadcast('refresh'); }; - $scope.setVariableValue = function(param, option) { - templateValuesSrv.setVariableValue(param, option); + $scope.getValuesForTag = function(variable, tagKey) { + return templateValuesSrv.getValuesForTag(variable, tagKey); + }; + + $scope.variableUpdated = function(variable) { + templateValuesSrv.variableUpdated(variable).then(function() { + dynamicDashboardSrv.update($scope.dashboard); + $rootScope.$emit('template-variable-value-updated'); + $rootScope.$broadcast('refresh'); + }); }; $scope.init(); diff --git a/public/app/features/dashboard/timeSrv.js b/public/app/features/dashboard/timeSrv.js index ef493e07e16..6bb9ccde223 100644 --- a/public/app/features/dashboard/timeSrv.js +++ b/public/app/features/dashboard/timeSrv.js @@ -93,7 +93,7 @@ define([ _.extend(this.time, time); // disable refresh if we have an absolute time - if (time.to !== 'now') { + if (_.isString(time.to) && time.to.indexOf('now') === -1) { this.old_refresh = this.dashboard.refresh || this.old_refresh; this.set_interval(false); } diff --git a/public/app/features/dashboard/unsavedChangesSrv.js b/public/app/features/dashboard/unsavedChangesSrv.js index d17276dc374..7318d4824d5 100644 --- a/public/app/features/dashboard/unsavedChangesSrv.js +++ b/public/app/features/dashboard/unsavedChangesSrv.js @@ -1,129 +1,114 @@ define([ 'angular', 'lodash', - 'config', ], -function(angular, _, config) { +function(angular, _) { 'use strict'; - if (!config.unsaved_changes_warning) { - return; - } - var module = angular.module('grafana.services'); - module.service('unsavedChangesSrv', function($rootScope, $modal, $q, $location, $timeout) { + module.service('unsavedChangesSrv', function($modal, $q, $location, $timeout, contextSrv, $window) { - var self = this; - var modalScope = $rootScope.$new(); + function Tracker(dashboard, scope) { + var self = this; - $rootScope.$on("dashboard-loaded", function(event, newDashboard) { - // wait for different services to patch the dashboard (missing properties) - $timeout(function() { - self.original = newDashboard.getSaveModelClone(); - self.current = newDashboard; - }, 1200); - }); + this.original = dashboard.getSaveModelClone(); + this.current = dashboard; + this.originalPath = $location.path(); + this.scope = scope; - $rootScope.$on("dashboard-saved", function(event, savedDashboard) { - self.original = savedDashboard.getSaveModelClone(); - self.current = savedDashboard; - self.orignalPath = $location.path(); - }); + // register events + scope.onAppEvent('dashboard-saved', function() { + self.original = self.current.getSaveModelClone(); + self.originalPath = $location.path(); + }); - $rootScope.$on("$routeChangeSuccess", function() { - self.original = null; - self.originalPath = $location.path(); - }); + $window.onbeforeunload = function() { + if (self.ignoreChanges()) { return; } + if (self.hasChanges()) { + return "There are unsaved changes to this dashboard"; + } + }; - this.ignoreChanges = function() { - if (!self.current) { return true; } - - var meta = self.current.meta; - return !meta.canSave || meta.fromScript || meta.fromFile; - }; - - window.onbeforeunload = function() { - if (self.ignoreChanges()) { return; } - if (self.has_unsaved_changes()) { - return "There are unsaved changes to this dashboard"; - } - }; - - this.init = function() { - $rootScope.$on("$locationChangeStart", function(event, next) { + scope.$on("$locationChangeStart", function(event, next) { // check if we should look for changes if (self.originalPath === $location.path()) { return true; } if (self.ignoreChanges()) { return true; } - if (self.has_unsaved_changes()) { + if (self.hasChanges()) { event.preventDefault(); self.next = next; - $timeout(self.open_modal); + $timeout(function() { + self.open_modal(); + }); } }); + } + + var p = Tracker.prototype; + + // for some dashboards and users + // changes should be ignored + p.ignoreChanges = function() { + if (!this.original) { return true; } + if (!contextSrv.isEditor) { return true; } + if (!this.current || !this.current.meta) { return true; } + + var meta = this.current.meta; + return !meta.canSave || meta.fromScript || meta.fromFile; }; - this.open_modal = function() { - var confirmModal = $modal({ - template: './app/partials/unsaved-changes.html', - modalClass: 'confirm-modal', - persist: true, - show: false, - scope: modalScope, - keyboard: false - }); + // remove stuff that should not count in diff + p.cleanDashboardFromIgnoredChanges = function(dash) { + // ignore time and refresh + dash.time = 0; + dash.refresh = 0; + dash.schemaVersion = 0; - $q.when(confirmModal).then(function(modalEl) { - modalEl.modal('show'); - }); - }; - - this.has_unsaved_changes = function() { - if (!self.original) { - return false; - } - - var current = self.current.getSaveModelClone(); - var original = self.original; - - // ignore timespan changes - current.time = original.time = {}; - current.refresh = original.refresh; - // ignore version - current.version = original.version; - - // ignore template variable values - _.each(current.templating.list, function(value, index) { - value.current = null; - value.options = null; - - if (original.templating.list.length > index) { - original.templating.list[index].current = null; - original.templating.list[index].options = null; + // filter row and panels properties that should be ignored + dash.rows = _.filter(dash.rows, function(row) { + if (row.repeatRowId) { + return false; } - }); - // ignore some panel and row stuff - current.forEachPanel(function(panel, panelIndex, row, rowIndex) { - var originalRow = original.rows[rowIndex]; - var originalPanel = original.getPanelById(panel.id); - // ignore row collapse state - if (originalRow) { - row.collapse = originalRow.collapse; - } - if (originalPanel) { - // ignore graph legend sort - if (originalPanel.legend && panel.legend) { - delete originalPanel.legend.sortDesc; - delete originalPanel.legend.sort; + row.panels = _.filter(row.panels, function(panel) { + if (panel.repeatPanelId) { + return false; + } + + // remove scopedVars + panel.scopedVars = null; + + // ignore panel legend sort + if (panel.legend) { delete panel.legend.sort; delete panel.legend.sortDesc; } - } + + return true; + }); + + // ignore collapse state + row.collapse = false; + return true; }); + // ignore template variable values + _.each(dash.templating.list, function(value) { + value.current = null; + value.options = null; + }); + + }; + + p.hasChanges = function() { + var current = this.current.getSaveModelClone(); + var original = this.original; + + this.cleanDashboardFromIgnoredChanges(current); + this.cleanDashboardFromIgnoredChanges(original); + var currentTimepicker = _.findWhere(current.nav, { type: 'timepicker' }); var originalTimepicker = _.findWhere(original.nav, { type: 'timepicker' }); @@ -141,28 +126,43 @@ function(angular, _, config) { return false; }; - this.goto_next = function() { + p.open_modal = function() { + var tracker = this; + + var modalScope = this.scope.$new(); + modalScope.ignore = function() { + tracker.original = null; + tracker.goto_next(); + }; + + modalScope.save = function() { + tracker.scope.$emit('save-dashboard'); + }; + + var confirmModal = $modal({ + template: './app/partials/unsaved-changes.html', + modalClass: 'confirm-modal', + persist: false, + show: false, + scope: modalScope, + keyboard: false + }); + + $q.when(confirmModal).then(function(modalEl) { + modalEl.modal('show'); + }); + }; + + p.goto_next = function() { var baseLen = $location.absUrl().length - $location.url().length; - var nextUrl = self.next.substring(baseLen); + var nextUrl = this.next.substring(baseLen); $location.url(nextUrl); }; - modalScope.ignore = function() { - self.original = null; - self.goto_next(); + this.Tracker = Tracker; + this.init = function(dashboard, scope) { + // wait for different services to patch the dashboard (missing properties) + $timeout(function() { new Tracker(dashboard, scope); }, 1200); }; - - modalScope.save = function() { - var unregister = $rootScope.$on('dashboard-saved', function() { - self.goto_next(); - }); - - $timeout(unregister, 2000); - - $rootScope.$emit('save-dashboard'); - }; - - }).run(function(unsavedChangesSrv) { - unsavedChangesSrv.init(); }); }); diff --git a/public/app/features/dashboard/viewStateSrv.js b/public/app/features/dashboard/viewStateSrv.js index 08f651f8d5b..e87a942a6de 100644 --- a/public/app/features/dashboard/viewStateSrv.js +++ b/public/app/features/dashboard/viewStateSrv.js @@ -125,13 +125,16 @@ function (angular, _, $) { }; DashboardViewState.prototype.enterFullscreen = function(panelScope) { + this.$scope.appEvent('hide-dash-editor'); + var docHeight = $(window).height(); var editHeight = Math.floor(docHeight * 0.3); var fullscreenHeight = Math.floor(docHeight * 0.7); - this.oldTimeRange = panelScope.range; - panelScope.height = this.state.edit ? editHeight : fullscreenHeight; - panelScope.editMode = this.state.edit; + panelScope.editMode = this.state.edit && this.$scope.dashboardMeta.canEdit; + panelScope.height = panelScope.editMode ? editHeight : fullscreenHeight; + + this.oldTimeRange = panelScope.range; this.fullscreenPanel = panelScope; $(window).scrollTop(0); diff --git a/public/app/features/dashlinks/editor.html b/public/app/features/dashlinks/editor.html new file mode 100644 index 00000000000..886550d9b9c --- /dev/null +++ b/public/app/features/dashlinks/editor.html @@ -0,0 +1,90 @@ +
    +
    Links and Dash Navigation
    + +
    +
    +
      +
    • + + +
    • +
    • + +
    • +
    + +
      +
    • + +
    • + +
    • Type
    • +
    • + +
    • + +
    • With tags
    • +
    • + + +
    • +
    • + +
    • +
    • + Title +
    • +
    • + +
    • +
    • Url
    • +
    • + +
    • +
    • + +
    • +
    +
    +
    +
    +
      +
    • + +
    • +
    • Title
    • +
    • + +
    • +
    • Tooltip
    • +
    • + +
    • +
    • Icon
    • +
    • + +
    • +
    +
    +
    +
    +
      +
    • + +
    • +
    • + +
    • +
    • + +
    • +
    +
    +
    +
    +
    +
    +
    + +
    + diff --git a/public/app/features/dashlinks/module.js b/public/app/features/dashlinks/module.js new file mode 100644 index 00000000000..9fc1bc4d4b5 --- /dev/null +++ b/public/app/features/dashlinks/module.js @@ -0,0 +1,192 @@ +define([ + 'angular', + 'lodash', +], +function (angular, _) { + 'use strict'; + + var module = angular.module('grafana.directives'); + + var iconMap = { + "external link": "fa-external-link", + "dashboard": "fa-th-large", + "question": "fa-question", + "info": "fa-info", + "bolt": "fa-bolt", + "doc": "fa-file-text-o", + "cloud": "fa-cloud", + }; + + module.directive('dashLinksEditor', function() { + return { + restrict: 'E', + controller: 'DashLinkEditorCtrl', + templateUrl: 'app/features/dashlinks/editor.html', + link: function() { + } + }; + }); + + module.directive('dashLinksContainer', function() { + return { + scope: { + links: "=" + }, + restrict: 'E', + controller: 'DashLinksContainerCtrl', + template: '', + link: function() { } + }; + }); + + module.directive('dashLink', function($compile, linkSrv) { + return { + restrict: 'E', + link: function(scope, elem) { + var link = scope.link; + var template = ' + diff --git a/public/app/features/templating/templateSrv.js b/public/app/features/templating/templateSrv.js index 09aed015386..40b661b0cbc 100644 --- a/public/app/features/templating/templateSrv.js +++ b/public/app/features/templating/templateSrv.js @@ -27,13 +27,25 @@ function (angular, _) { this._texts = {}; _.each(this.variables, function(variable) { - if (!variable.current || !variable.current.value) { return; } + if (!variable.current || !variable.current.isNone && !variable.current.value) { return; } - this._values[variable.name] = variable.current.value; + this._values[variable.name] = this.renderVariableValue(variable); this._texts[variable.name] = variable.current.text; }, this); }; + this.renderVariableValue = function(variable) { + var value = variable.current.value; + if (_.isString(value)) { + return value; + } else { + if (variable.multiFormat === 'regex values') { + return '(' + value.join('|') + ')'; + } + return '{' + value.join(',') + '}'; + } + }; + this.setGrafanaVariable = function (name, value) { this._grafanaVariables[name] = value; }; @@ -64,7 +76,7 @@ function (angular, _) { }; this.replace = function(target, scopedVars) { - if (!target) { return; } + if (!target) { return target; } var value; this._regex.lastIndex = 0; @@ -83,7 +95,7 @@ function (angular, _) { }; this.replaceWithText = function(target, scopedVars) { - if (!target) { return; } + if (!target) { return target; } var value; var text; @@ -103,6 +115,20 @@ function (angular, _) { }); }; + this.fillVariableValuesForUrl = function(params) { + var toUrlVal = function(current) { + if (current.text === 'All') { + return 'All'; + } else { + return current.value; + } + }; + + _.each(this.variables, function(variable) { + params['var-' + variable.name] = toUrlVal(variable.current); + }); + }; + }); }); diff --git a/public/app/features/templating/templateValuesSrv.js b/public/app/features/templating/templateValuesSrv.js index e933c16796f..a5767ffa312 100644 --- a/public/app/features/templating/templateValuesSrv.js +++ b/public/app/features/templating/templateValuesSrv.js @@ -11,6 +11,8 @@ function (angular, _, kbn) { module.service('templateValuesSrv', function($q, $rootScope, datasourceSrv, $location, templateSrv, timeSrv) { var self = this; + function getNoneOption() { return { text: 'None', value: '', isNone: true }; } + $rootScope.onAppEvent('time-range-changed', function() { var variable = _.findWhere(self.variables, { type: 'interval' }); if (variable) { @@ -29,13 +31,7 @@ function (angular, _, kbn) { var variable = this.variables[i]; var urlValue = queryParams['var-' + variable.name]; if (urlValue !== void 0) { - var option = _.findWhere(variable.options, { text: urlValue }); - option = option || { text: urlValue, value: urlValue }; - - var promise = this.setVariableValue(variable, option, true); - this.updateAutoInterval(variable); - - promises.push(promise); + promises.push(this.setVariableFromUrl(variable, urlValue)); } else if (variable.refresh) { promises.push(this.updateOptions(variable)); @@ -48,6 +44,25 @@ function (angular, _, kbn) { return $q.all(promises); }; + this.setVariableFromUrl = function(variable, urlValue) { + if (variable.refresh) { + var self = this; + //refresh the list of options before setting the value + return this.updateOptions(variable).then(function() { + var option = _.findWhere(variable.options, { text: urlValue }); + option = option || { text: urlValue, value: urlValue }; + + self.updateAutoInterval(variable); + return self.setVariableValue(variable, option); + }); + } + var option = _.findWhere(variable.options, { text: urlValue }); + option = option || { text: urlValue, value: urlValue }; + + this.updateAutoInterval(variable); + return this.setVariableValue(variable, option); + }; + this.updateAutoInterval = function(variable) { if (!variable.auto) { return; } @@ -60,17 +75,20 @@ function (angular, _, kbn) { templateSrv.setGrafanaVariable('$__auto_interval', interval); }; - this.setVariableValue = function(variable, option, recursive) { - variable.current = option; + this.setVariableValue = function(variable, option) { + variable.current = angular.copy(option); + + if (_.isArray(variable.current.value)) { + variable.current.text = variable.current.value.join(' + '); + } templateSrv.updateTemplateData(); + return this.updateOptionsInChildVariables(variable); + }; - return this.updateOptionsInChildVariables(variable) - .then(function() { - if (!recursive) { - $rootScope.$broadcast('refresh'); - } - }); + this.variableUpdated = function(variable) { + templateSrv.updateTemplateData(); + return this.updateOptionsInChildVariables(variable); }; this.updateOptionsInChildVariables = function(updatedVariable) { @@ -104,24 +122,75 @@ function (angular, _, kbn) { return $q.when([]); } - return datasourceSrv.get(variable.datasource).then(function(datasource) { - return datasource.metricFindQuery(variable.query).then(function (results) { - variable.options = self.metricNamesToVariableValues(variable, results); + return datasourceSrv.get(variable.datasource) + .then(_.partial(this.updateOptionsFromMetricFindQuery, variable)) + .then(_.partial(this.updateTags, variable)) + .then(_.partial(this.validateVariableSelectionState, variable)); + }; - if (variable.includeAll) { - self.addAllOption(variable); - } + this.validateVariableSelectionState = function(variable) { + if (!variable.current) { + if (!variable.options.length) { return; } + return self.setVariableValue(variable, variable.options[0]); + } - // if parameter has current value - // if it exists in options array keep value - if (variable.current) { - var currentOption = _.findWhere(variable.options, { text: variable.current.text }); - if (currentOption) { - return self.setVariableValue(variable, currentOption, true); + if (_.isArray(variable.current.value)) { + for (var i = 0; i < variable.current.value.length; i++) { + var value = variable.current.value[i]; + for (var y = 0; y < variable.options.length; y++) { + var option = variable.options[y]; + if (option.value === value) { + option.selected = true; } } + } + } else { + var currentOption = _.findWhere(variable.options, { text: variable.current.text }); + if (currentOption) { + return self.setVariableValue(variable, currentOption); + } else { + if (!variable.options.length) { return; } + return self.setVariableValue(variable, variable.options[0]); + } + } + }; - return self.setVariableValue(variable, variable.options[0], true); + this.updateTags = function(variable, datasource) { + if (variable.useTags) { + return datasource.metricFindQuery(variable.tagsQuery).then(function (results) { + variable.tags = []; + for (var i = 0; i < results.length; i++) { + variable.tags.push(results[i].text); + } + return datasource; + }); + } else { + delete variable.tags; + } + + return datasource; + }; + + this.updateOptionsFromMetricFindQuery = function(variable, datasource) { + return datasource.metricFindQuery(variable.query).then(function (results) { + variable.options = self.metricNamesToVariableValues(variable, results); + if (variable.includeAll) { + self.addAllOption(variable); + } + if (!variable.options.length) { + variable.options.push(getNoneOption()); + } + return datasource; + }); + }; + + this.getValuesForTag = function(variable, tagKey) { + return datasourceSrv.get(variable.datasource).then(function(datasource) { + var query = variable.tagValuesQuery.replace('$tag', tagKey); + return datasource.metricFindQuery(query).then(function (results) { + return _.map(results, function(value) { + return value.text; + }); }); }); }; @@ -148,7 +217,7 @@ function (angular, _, kbn) { options[value] = value; } - return _.map(_.keys(options), function(key) { + return _.map(_.keys(options).sort(), function(key) { return { text: key, value: key }; }); }; @@ -156,19 +225,19 @@ function (angular, _, kbn) { this.addAllOption = function(variable) { var allValue = ''; switch(variable.allFormat) { - case 'wildcard': - allValue = '*'; - break; - case 'regex wildcard': - allValue = '.*'; - break; - case 'regex values': - allValue = '(' + _.pluck(variable.options, 'text').join('|') + ')'; - break; - default: - allValue = '{'; - allValue += _.pluck(variable.options, 'text').join(','); - allValue += '}'; + case 'wildcard': + allValue = '*'; + break; + case 'regex wildcard': + allValue = '.*'; + break; + case 'regex values': + allValue = '(' + _.pluck(variable.options, 'text').join('|') + ')'; + break; + default: + allValue = '{'; + allValue += _.pluck(variable.options, 'text').join(','); + allValue += '}'; } variable.options.unshift({text: 'All', value: allValue}); diff --git a/public/app/filters/all.js b/public/app/filters/all.js index e75d70043d8..c904c7c9449 100755 --- a/public/app/filters/all.js +++ b/public/app/filters/all.js @@ -56,8 +56,12 @@ define(['angular', 'jquery', 'lodash', 'moment'], function (angular, $, _, momen }); module.filter('interpolateTemplateVars', function(templateSrv) { - function interpolateTemplateVars(text) { - return templateSrv.replaceWithText(text); + function interpolateTemplateVars(text, scope) { + if (scope.panel) { + return templateSrv.replaceWithText(text, scope.panel.scopedVars); + } else { + return templateSrv.replaceWithText(text, scope.row.scopedVars); + } } interpolateTemplateVars.$stateful = true; diff --git a/public/app/panels/dashlist/editor.html b/public/app/panels/dashlist/editor.html index 578d9e4b2d2..7b176b74317 100644 --- a/public/app/panels/dashlist/editor.html +++ b/public/app/panels/dashlist/editor.html @@ -7,7 +7,7 @@ Mode
  • - +
  • @@ -23,15 +23,15 @@ Query
  • -
  • - Tag + Tags
  • - + +
  • @@ -47,7 +47,7 @@ Limit number to
  • - +
  • diff --git a/public/app/panels/dashlist/module.html b/public/app/panels/dashlist/module.html index b59822e09a2..de0e23a9ea5 100644 --- a/public/app/panels/dashlist/module.html +++ b/public/app/panels/dashlist/module.html @@ -1,7 +1,7 @@
    - + {{dash.title}} diff --git a/public/app/panels/dashlist/module.js b/public/app/panels/dashlist/module.js index 098f5acd956..3e7c8c5587c 100644 --- a/public/app/panels/dashlist/module.js +++ b/public/app/panels/dashlist/module.js @@ -21,7 +21,7 @@ function (angular, app, _, config, PanelMeta) { module.controller('DashListPanelCtrl', function($scope, panelSrv, backendSrv) { $scope.panelMeta = new PanelMeta({ - panelName: 'Dash list', + panelName: 'Dashboard list', editIcon: "fa fa-star", fullscreen: true, }); @@ -32,7 +32,7 @@ function (angular, app, _, config, PanelMeta) { mode: 'starred', query: '', limit: 10, - tag: '', + tags: [] }; $scope.modes = ['starred', 'search']; @@ -43,6 +43,9 @@ function (angular, app, _, config, PanelMeta) { $scope.init = function() { panelSrv.init($scope); + if ($scope.panel.tag) { + $scope.panel.tags = [$scope.panel.tag]; + } if ($scope.isNewPanel()) { $scope.panel.title = "Starred Dashboards"; @@ -58,11 +61,12 @@ function (angular, app, _, config, PanelMeta) { params.starred = "true"; } else { params.query = $scope.panel.query; - params.tag = $scope.panel.tag; + params.tag = $scope.panel.tags; } return backendSrv.search(params).then(function(result) { - $scope.dashList = result.dashboards; + $scope.dashList = result; + $scope.panelRenderingComplete(); }); }; diff --git a/public/app/panels/graph/axisEditor.html b/public/app/panels/graph/axisEditor.html index ae69ec60929..d161330f5c0 100644 --- a/public/app/panels/graph/axisEditor.html +++ b/public/app/panels/graph/axisEditor.html @@ -190,7 +190,7 @@
      -
    • +
    • Legend values
    • diff --git a/public/app/panels/graph/graph.js b/public/app/panels/graph/graph.js index 795aee03c20..0f6ae7baeeb 100755 --- a/public/app/panels/graph/graph.js +++ b/public/app/panels/graph/graph.js @@ -112,7 +112,7 @@ function (angular, $, kbn, moment, _, GraphTooltip) { } if (elem.width() === 0) { - return; + return true; } } @@ -247,22 +247,26 @@ function (angular, $, kbn, moment, _, GraphTooltip) { sortedSeries = _.sortBy(data, function(series) { return series.zindex; }); - function callPlot() { + function callPlot(incrementRenderCounter) { try { $.plot(elem, sortedSeries, options); } catch (e) { console.log('flotcharts error', e); } + + if (incrementRenderCounter) { + scope.panelRenderingComplete(); + } } if (shouldDelayDraw(panel)) { // temp fix for legends on the side, need to render twice to get dimensions right - callPlot(); - setTimeout(callPlot, 50); + callPlot(false); + setTimeout(function() { callPlot(true); }, 50); legendSideLastValue = panel.legend.rightSide; } else { - callPlot(); + callPlot(true); } } @@ -277,7 +281,6 @@ function (angular, $, kbn, moment, _, GraphTooltip) { if (legendSideLastValue !== null && panel.legend.rightSide !== legendSideLastValue) { return true; } - return false; } function addTimeAxis(options) { @@ -370,7 +373,7 @@ function (angular, $, kbn, moment, _, GraphTooltip) { if (_.findWhere(data, {yaxis: 2})) { var secondY = _.clone(defaults); secondY.index = 2, - secondY.logBase = scope.panel.grid.rightLogBase || 2, + secondY.logBase = scope.panel.grid.rightLogBase || 1, secondY.position = 'right'; secondY.min = scope.panel.grid.rightMin; secondY.max = scope.panel.percentage && scope.panel.stack ? 100 : scope.panel.grid.rightMax; @@ -481,6 +484,9 @@ function (angular, $, kbn, moment, _, GraphTooltip) { case 'bps': url += '&yUnitSystem=si'; break; + case 'pps': + url += '&yUnitSystem=si'; + break; case 'Bps': url += '&yUnitSystem=si'; break; diff --git a/public/app/panels/graph/legend.js b/public/app/panels/graph/legend.js index fb07275310d..beea27ec8b7 100644 --- a/public/app/panels/graph/legend.js +++ b/public/app/panels/graph/legend.js @@ -41,7 +41,7 @@ function (angular, app, _, kbn, $) { var popoverScope = scope.$new(); popoverScope.series = seriesInfo; popoverSrv.show({ - element: $(':first-child', el), + element: el, templateUrl: 'app/panels/graph/legend.popover.html', scope: popoverScope }); @@ -130,6 +130,10 @@ function (angular, app, _, kbn, $) { if (panel.legend.hideEmpty && series.allIsNull) { continue; } + // ignore series excluded via override + if (!series.legend) { + continue; + } var html = '
      -   diff --git a/public/app/panels/graph/module.js b/public/app/panels/graph/module.js index b9fff58f670..867f864123c 100644 --- a/public/app/panels/graph/module.js +++ b/public/app/panels/graph/module.js @@ -29,7 +29,7 @@ function (angular, app, $, _, kbn, moment, TimeSeries, PanelMeta) { panelName: 'Graph', editIcon: "fa fa-bar-chart", fullscreen: true, - metricsEditor: true + metricsEditor: true, }); $scope.panelMeta.addEditorTab('Axes & Grid', 'app/panels/graph/axisEditor.html'); @@ -116,7 +116,7 @@ function (angular, app, $, _, kbn, moment, TimeSeries, PanelMeta) { _.defaults($scope.panel.grid, _d.grid); _.defaults($scope.panel.legend, _d.legend); - $scope.logScales = {'linear': 1, 'log (base 10)': 10, 'log (base 32)': 32, 'log (base 1024)': 1024}; + $scope.logScales = {'linear': 1, 'log (base 2)': 2, 'log (base 10)': 10, 'log (base 32)': 32, 'log (base 1024)': 1024}; $scope.hiddenSeries = {}; $scope.seriesList = []; @@ -197,7 +197,7 @@ function (angular, app, $, _, kbn, moment, TimeSeries, PanelMeta) { }; $scope.render = function(data) { - $scope.$broadcast('render', data); + panelHelper.broadcastRender($scope, data); }; $scope.changeSeriesColor = function(series, color) { diff --git a/public/app/panels/graph/seriesOverridesCtrl.js b/public/app/panels/graph/seriesOverridesCtrl.js index 80fb2ead6c2..3ded0c9ffdb 100644 --- a/public/app/panels/graph/seriesOverridesCtrl.js +++ b/public/app/panels/graph/seriesOverridesCtrl.js @@ -1,14 +1,15 @@ define([ 'angular', + 'jquery', 'app', 'lodash', -], function(angular, app, _) { +], function(angular, jquery, app, _) { 'use strict'; var module = angular.module('grafana.panels.graph', []); app.useModule(module); - module.controller('SeriesOverridesCtrl', function($scope) { + module.controller('SeriesOverridesCtrl', function($scope, $element, popoverSrv) { $scope.overrideMenu = []; $scope.currentOverrides = []; $scope.override = $scope.override || {}; @@ -28,6 +29,12 @@ define([ }; $scope.setOverride = function(item, subItem) { + // handle color overrides + if (item.propertyName === 'color') { + $scope.openColorSelector(); + return; + } + $scope.override[item.propertyName] = subItem.value; // automatically disable lines for this series and the fill bellow to series @@ -41,6 +48,24 @@ define([ $scope.render(); }; + $scope.colorSelected = function(color) { + $scope.override['color'] = color; + $scope.updateCurrentOverrides(); + $scope.render(); + }; + + $scope.openColorSelector = function() { + var popoverScope = $scope.$new(); + popoverScope.colorSelected = $scope.colorSelected; + + popoverSrv.show({ + element: $element.find(".dropdown"), + placement: 'top', + templateUrl: 'app/partials/colorpicker.html', + scope: popoverScope + }); + }; + $scope.removeOverride = function(option) { delete $scope.override[option.propertyName]; $scope.updateCurrentOverrides(); @@ -74,9 +99,12 @@ define([ $scope.addOverrideOption('Staircase line', 'steppedLine', [true, false]); $scope.addOverrideOption('Points', 'points', [true, false]); $scope.addOverrideOption('Points Radius', 'pointradius', [1,2,3,4,5]); - $scope.addOverrideOption('Stack', 'stack', [true, false, 2, 3, 4, 5]); + $scope.addOverrideOption('Stack', 'stack', [true, false, 'A', 'B', 'C', 'D']); + $scope.addOverrideOption('Color', 'color', ['change']); $scope.addOverrideOption('Y-axis', 'yaxis', [1, 2]); $scope.addOverrideOption('Z-index', 'zindex', [-1,-2,-3,0,1,2,3]); + $scope.addOverrideOption('Transform', 'transform', ['negative-Y']); + $scope.addOverrideOption('Legend', 'legend', [true, false]); $scope.updateCurrentOverrides(); }); diff --git a/public/app/panels/graph/styleEditor.html b/public/app/panels/graph/styleEditor.html index a5d82bba262..5d5f2fd7401 100644 --- a/public/app/panels/graph/styleEditor.html +++ b/public/app/panels/graph/styleEditor.html @@ -73,27 +73,30 @@
    • alias or regex
    • +
    • - +
    • +
    • - {{option.name}}: {{option.value}} + + Color: + + + {{option.name}}: {{option.value}} +
    • -
    - +
    diff --git a/public/app/panels/singlestat/module.js b/public/app/panels/singlestat/module.js index a43ac2d0b69..b38912605bb 100644 --- a/public/app/panels/singlestat/module.js +++ b/public/app/panels/singlestat/module.js @@ -170,17 +170,7 @@ function (angular, app, _, TimeSeries, kbn, PanelMeta) { $scope.render = function() { var data = {}; - if (!$scope.series || $scope.series.length === 0) { - data.flotpairs = []; - data.mainValue = Number.NaN; - data.mainValueFormated = $scope.getFormatedValue(null); - } - else { - var series = $scope.series[0]; - data.mainValue = series.stats[$scope.panel.valueName]; - data.mainValueFormated = $scope.getFormatedValue(data.mainValue); - data.flotpairs = series.flotpairs; - } + $scope.setValues(data); data.thresholds = $scope.panel.thresholds.split(',').map(function(strVale) { return Number(strVale.trim()); @@ -192,32 +182,49 @@ function (angular, app, _, TimeSeries, kbn, PanelMeta) { $scope.$broadcast('render'); }; - $scope.getFormatedValue = function(mainValue) { + $scope.setValues = function(data) { + data.flotpairs = []; - // first check value to text mappings + if ($scope.series && $scope.series.length > 0) { + var lastValue = _.last($scope.series[0].datapoints)[0]; + if (_.isString(lastValue)) { + data.value = 0; + data.valueFormated = lastValue; + data.valueRounded = 0; + } else { + data.value = $scope.series[0].stats[$scope.panel.valueName]; + data.flotpairs = $scope.series[0].flotpairs; + + var decimalInfo = $scope.getDecimalsForValue(data.value); + var formatFunc = kbn.valueFormats[$scope.panel.format]; + data.valueFormated = formatFunc(data.value, decimalInfo.decimals, decimalInfo.scaledDecimals); + data.valueRounded = kbn.roundValue(data.value, decimalInfo.decimals); + } + } + + // check value to text mappings for(var i = 0; i < $scope.panel.valueMaps.length; i++) { var map = $scope.panel.valueMaps[i]; // special null case if (map.value === 'null') { - if (mainValue === null || mainValue === void 0) { - return map.text; + if (data.value === null || data.value === void 0) { + data.valueFormated = map.text; + return; } continue; } + // value/number to text mapping var value = parseFloat(map.value); - if (value === mainValue) { - return map.text; + if (value === data.value) { + data.valueFormated = map.text; + return; } } - if (mainValue === null || mainValue === void 0) { - return "no value"; + if (data.value === null || data.value === void 0) { + data.valueFormated = "no value"; } - - var decimalInfo = $scope.getDecimalsForValue(mainValue); - var formatFunc = kbn.valueFormats[$scope.panel.format]; - return formatFunc(mainValue, decimalInfo.decimals, decimalInfo.scaledDecimals); }; $scope.removeValueMap = function(map) { diff --git a/public/app/panels/singlestat/singleStatPanel.js b/public/app/panels/singlestat/singleStatPanel.js index 49c248bc5cd..2463ca1e38e 100644 --- a/public/app/panels/singlestat/singleStatPanel.js +++ b/public/app/panels/singlestat/singleStatPanel.js @@ -20,6 +20,7 @@ function (angular, app, _, $) { scope.$on('render', function() { render(); + scope.panelRenderingComplete(); }); function setElementHeight() { @@ -73,7 +74,7 @@ function (angular, app, _, $) { if (panel.prefix) { body += getSpan('singlestat-panel-prefix', panel.prefixFontSize, scope.panel.prefix); } - var value = applyColoringThresholds(data.mainValue, data.mainValueFormated); + var value = applyColoringThresholds(data.valueRounded, data.valueFormated); body += getSpan('singlestat-panel-value', panel.valueFontSize, value); if (panel.postfix) { body += getSpan('singlestat-panel-postfix', panel.postfixFontSize, panel.postfix); } @@ -147,8 +148,8 @@ function (angular, app, _, $) { var body = getBigValueHtml(); - if (panel.colorBackground && !isNaN(data.mainValue)) { - var color = getColorForValue(data.mainValue); + if (panel.colorBackground && !isNaN(data.valueRounded)) { + var color = getColorForValue(data.valueRounded); if (color) { $panelContainer.css('background-color', color); if (scope.fullscreen) { @@ -181,9 +182,13 @@ function (angular, app, _, $) { elem.click(function() { if (panel.links.length === 0) { return; } - - var linkInfo = linkSrv.getPanelLinkAnchorInfo(panel.links[0]); - if (linkInfo.href[0] === '#') { linkInfo.href = linkInfo.href.substring(1); } + var link = panel.links[0]; + var linkInfo = linkSrv.getPanelLinkAnchorInfo(link); + if (panel.links[0].targetBlank) { + var redirectWindow = window.open(linkInfo.href, '_blank'); + redirectWindow.location; + return; + } if (linkInfo.href.indexOf('http') === 0) { window.location.href = linkInfo.href; diff --git a/public/app/panels/text/module.js b/public/app/panels/text/module.js index c5e82cd4199..436a9982b41 100644 --- a/public/app/panels/text/module.js +++ b/public/app/panels/text/module.js @@ -61,6 +61,7 @@ function (angular, app, _, require, PanelMeta) { else if ($scope.panel.mode === 'text') { $scope.renderText($scope.panel.content); } + $scope.panelRenderingComplete(); }; $scope.renderText = function(content) { diff --git a/public/app/partials/colorpicker.html b/public/app/partials/colorpicker.html new file mode 100644 index 00000000000..f687b740bbf --- /dev/null +++ b/public/app/partials/colorpicker.html @@ -0,0 +1,11 @@ +
    + × + +
    +   +
    + +
    + diff --git a/public/app/partials/dashboard.html b/public/app/partials/dashboard.html index 1384c41c1ee..57c9cb74521 100644 --- a/public/app/partials/dashboard.html +++ b/public/app/partials/dashboard.html @@ -21,10 +21,10 @@
    -
    +
    -