Merge branch 'master' into WPH95-feature/add_es_alerting

This commit is contained in:
Marcus Efraimsson 2018-05-08 17:13:08 +02:00
commit cde347bd3d
No known key found for this signature in database
GPG Key ID: EBFE0FB04612DD4A
83 changed files with 1241 additions and 438 deletions

18
.dockerignore Normal file
View File

@ -0,0 +1,18 @@
.awcache
.dockerignore
.git
.gitignore
.github
data*
dist
docker
docs
dump.rdb
node_modules
/local
/tmp
/vendor
*.yml
*.md
/vendor
/tmp

2
.gitignore vendored
View File

@ -44,7 +44,9 @@ docker-compose.yaml
/conf/provisioning/**/custom.yaml
profile.cov
/grafana
/local
.notouch
/Makefile.local
/pkg/cmd/grafana-cli/grafana-cli
/pkg/cmd/grafana-server/grafana-server
/pkg/cmd/grafana-server/debug

View File

@ -4,6 +4,21 @@
* **Graph**: Show invisible highest value bucket in histogram [#11498](https://github.com/grafana/grafana/issues/11498)
* **Dashboard**: Enable "Save As..." if user has edit permission [#11625](https://github.com/grafana/grafana/issues/11625)
* **Prometheus**: Table columns order now changes when rearrange queries [#11690](https://github.com/grafana/grafana/issues/11690), thx [@mtanda](https://github.com/mtanda)
* **Variables**: Fix variable interpolation when using multiple formatting types [#11800](https://github.com/grafana/grafana/issues/11800), thx [@svenklemm](https://github.com/svenklemm)
* **Dashboard**: Fix date selector styling for dark/light theme in time picker control [#11616](https://github.com/grafana/grafana/issues/11616)
* **Discord**: Alert notification channel type for Discord, [#7964](https://github.com/grafana/grafana/issues/7964) thx [@jereksel](https://github.com/jereksel),
* **InfluxDB**: Support SELECT queries in templating query, [#5013](https://github.com/grafana/grafana/issues/5013)
* **Dashboard**: JSON Model under dashboard settings can now be updated & changes saved, [#1429](https://github.com/grafana/grafana/issues/1429), thx [@jereksel](https://github.com/jereksel)
* **Security**: Fix XSS vulnerabilities in dashboard links [#11813](https://github.com/grafana/grafana/pull/11813)
* **Singlestat**: Fix "time of last point" shows local time when dashboard timezone set to UTC [#10338](https://github.com/grafana/grafana/issues/10338)
# 5.1.1 (2018-05-07)
* **LDAP**: LDAP login with MariaDB/MySQL database and dn>100 chars not possible [#11754](https://github.com/grafana/grafana/issues/11754)
* **Build**: AppVeyor Windows build missing version and commit info [#11758](https://github.com/grafana/grafana/issues/11758)
* **Scroll**: Scroll can't start in graphs on Chrome mobile [#11710](https://github.com/grafana/grafana/issues/11710)
* **Units**: Revert renaming of unit key ppm [#11743](https://github.com/grafana/grafana/issues/11743)
# 5.1.0 (2018-04-26)

View File

@ -1,3 +1,5 @@
-include local/Makefile
all: deps build
deps-go:

View File

@ -1,26 +1,20 @@
# Roadmap (2018-02-22)
# Roadmap (2018-05-06)
This roadmap is a tentative plan for the core development team. Things change constantly as PRs come in and priorities change.
But it will give you an idea of our current vision and plan.
### Short term (1-2 months)
- v5.1
- Build speed improvements & integration test execution
- Kubernetes friendly docker container
- Enterprise LDAP
- Provisioning workflow
- MSSQL datasource
- Elasticsearch alerting
- Crossplatform builds
- Backend service refactorings
- Explore UI
- First login registration view
### Mid term (2-4 months)
- v5.2
- Azure monitor backend rewrite
- Elasticsearch alerting
- First login registration view
- Backend plugins? (alert notifiers, auth)
- Crossplatform builds
- IFQL Initial support
- Multi-Stat panel
- React Panels
- Templating Query Editor UI Plugin hook
### Long term (4 - 8 months)

View File

@ -2,7 +2,7 @@
# http://localhost:3000 (Grafana running locally)
#
# Please note that you'll need to change the root_url in the Grafana configuration:
# root_url = %(protocol)s://%(domain)s:/grafana/
# root_url = %(protocol)s://%(domain)s:10081/grafana/
apacheproxy:
build: blocks/apache_proxy

View File

@ -2,7 +2,7 @@
# http://localhost:3000 (Grafana running locally)
#
# Please note that you'll need to change the root_url in the Grafana configuration:
# root_url = %(protocol)s://%(domain)s:/grafana/
# root_url = %(protocol)s://%(domain)s:10080/grafana/
nginxproxy:
build: blocks/nginx_proxy

View File

@ -100,8 +100,8 @@ In the table below you can see some examples and you can find all different opti
Filter Option | Example | Raw | Interpolated | Description
------------ | ------------- | ------------- | ------------- | -------------
`glob` | ${servers:glob} | `'test1', 'test2'` | `{test1,test2}` | Formats multi-value variable into a glob
`regex` | ${servers:regex} | `'test.', 'test2'` | `(test\\.|test2)` | Formats multi-value variable into a regex string
`pipe` | ${servers:pipe} | `'test.', 'test2'` | `test.|test2` | Formats multi-value variable into a pipe-separated string
`regex` | ${servers:regex} | `'test.', 'test2'` | <code>(test\.&#124;test2)</code> | Formats multi-value variable into a regex string
`pipe` | ${servers:pipe} | `'test.', 'test2'` | <code>test.&#124;test2</code> | Formats multi-value variable into a pipe-separated string
`csv`| ${servers:csv} | `'test1', 'test2'` | `test1,test2` | Formats multi-value variable as a comma-separated string
## Improved workflow for provisioned dashboards
@ -122,4 +122,4 @@ More information in the [Provisioning documentation](/features/datasources/prome
## Changelog
Checkout the [CHANGELOG.md](https://github.com/grafana/grafana/blob/master/CHANGELOG.md) file for a complete list
of new features, changes, and bug fixes.
of new features, changes, and bug fixes.

View File

@ -53,7 +53,7 @@ server {
```bash
[server]
domain = foo.bar
root_url = %(protocol)s://%(domain)s:/grafana
root_url = %(protocol)s://%(domain)s/grafana/
```
#### Nginx configuration with sub path
@ -98,7 +98,7 @@ Given:
```bash
[server]
domain = localhost:8080
root_url = %(protocol)s://%(domain)s:/grafana
root_url = %(protocol)s://%(domain)s/grafana/
```
Create an Inbound Rule for the parent website (localhost:8080 in this example) in IIS Manager with the following settings:

View File

@ -659,6 +659,10 @@ Set to `true` to enable auto sign up of users who do not exist in Grafana DB. De
Limit where auth proxy requests come from by configuring a list of IP addresses. This can be used to prevent users spoofing the X-WEBAUTH-USER header.
### headers
Used to define additional headers for `Name`, `Email` and/or `Login`, for example if the user's name is sent in the X-WEBAUTH-NAME header and their email address in the X-WEBAUTH-EMAIL header, set `headers = Name:X-WEBAUTH-NAME Email:X-WEBAUTH-EMAIL`.
<hr>
## [session]

View File

@ -15,7 +15,7 @@ weight = 1
Description | Download
------------ | -------------
Stable for Debian-based Linux | [grafana_5.1.0_amd64.deb](https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana_5.1.0_amd64.deb)
Stable for Debian-based Linux | [grafana_5.1.1_amd64.deb](https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana_5.1.1_amd64.deb)
<!--
Beta for Debian-based Linux | [grafana_5.1.0-beta1_amd64.deb](https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana_5.1.0-beta1_amd64.deb)
-->
@ -27,9 +27,9 @@ installation.
```bash
wget https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana_5.1.0_amd64.deb
wget https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana_5.1.1_amd64.deb
sudo apt-get install -y adduser libfontconfig
sudo dpkg -i grafana_5.1.0_amd64.deb
sudo dpkg -i grafana_5.1.1_amd64.deb
```
<!-- ## Install Latest Beta

View File

@ -15,7 +15,7 @@ weight = 2
Description | Download
------------ | -------------
Stable for CentOS / Fedora / OpenSuse / Redhat Linux | [5.1.0 (x86-64 rpm)](https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana-5.1.0-1.x86_64.rpm)
Stable for CentOS / Fedora / OpenSuse / Redhat Linux | [5.1.1 (x86-64 rpm)](https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana-5.1.1-1.x86_64.rpm)
<!--
Latest Beta for CentOS / Fedora / OpenSuse / Redhat Linux | [5.1.0-beta1 (x86-64 rpm)](https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana-5.1.0-beta1.x86_64.rpm)
-->
@ -28,7 +28,7 @@ installation.
You can install Grafana using Yum directly.
```bash
$ sudo yum install https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana-5.1.0-1.x86_64.rpm
$ sudo yum install https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana-5.1.1-1.x86_64.rpm
```
<!-- ## Install Beta
@ -42,15 +42,15 @@ Or install manually using `rpm`.
#### On CentOS / Fedora / Redhat:
```bash
$ wget https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana-5.1.0-1.x86_64.rpm
$ wget https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana-5.1.1-1.x86_64.rpm
$ sudo yum install initscripts fontconfig
$ sudo rpm -Uvh grafana-5.1.0-1.x86_64.rpm
$ sudo rpm -Uvh grafana-5.1.1-1.x86_64.rpm
```
#### On OpenSuse:
```bash
$ sudo rpm -i --nodeps grafana-5.1.0-1.x86_64.rpm
$ sudo rpm -i --nodeps grafana-5.1.1-1.x86_64.rpm
```
## Install via YUM Repository

View File

@ -12,7 +12,7 @@ weight = 3
Description | Download
------------ | -------------
Latest stable package for Windows | [grafana-5.1.0.windows-x64.zip](https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana-5.1.0.windows-x64.zip)
Latest stable package for Windows | [grafana-5.1.1.windows-x64.zip](https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana-5.1.1.windows-x64.zip)
<!--
Latest beta package for Windows | [grafana.5.1.0-beta1.windows-x64.zip](https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana-5.0.0-beta5.windows-x64.zip)

View File

@ -5,7 +5,7 @@ type = "docs"
[menu.docs]
name = "Developing App Plugins"
parent = "developing"
weight = 6
weight = 4
+++
# Grafana Apps

View File

@ -5,7 +5,7 @@ type = "docs"
[menu.docs]
name = "Developing Datasource Plugins"
parent = "developing"
weight = 6
weight = 5
+++
# Datasources

View File

@ -1,16 +1,11 @@
---
page_title: Plugin panel
page_description: Panel plugins for Grafana
page_keywords: grafana, plugins, documentation
---
+++
title = "Installing Plugins"
title = "Developing Panel Plugins"
keywords = ["grafana", "plugins", "panel", "documentation"]
type = "docs"
[menu.docs]
name = "Developing Panel Plugins"
parent = "developing"
weight = 1
weight = 4
+++
@ -20,7 +15,21 @@ Panels are the main building blocks of dashboards.
## Panel development
Examples
### Scrolling
The grafana dashboard framework controls the panel height. To enable a scrollbar within the panel the PanelCtrl needs to set the scrollable static variable:
```javascript
export class MyPanelCtrl extends PanelCtrl {
static scrollable = true;
...
```
In this case, make sure the template has a single `<div>...</div>` root. The plugin loader will modifiy that element adding a scrollbar.
### Examples
- [clock-panel](https://github.com/grafana/clock-panel)
- [singlestat-panel](https://github.com/grafana/grafana/blob/master/public/app/plugins/panel/singlestat/module.ts)

View File

@ -5,7 +5,7 @@ type = "docs"
[menu.docs]
name = "plugin.json Schema"
parent = "developing"
weight = 6
weight = 8
+++
# Plugin.json

View File

@ -16,7 +16,7 @@ Example:
- Parent site: http://localhost:8080
- Grafana: http://localhost:3000
Grafana as a subpath: http://localhost:8080/grafana
Grafana as a subpath: http://localhost:8080/grafana
## Setup
@ -33,7 +33,7 @@ Given that the subpath should be `grafana` and the parent site is `localhost:808
```bash
[server]
domain = localhost:8080
root_url = %(protocol)s://%(domain)s:/grafana
root_url = %(protocol)s://%(domain)s/grafana/
```
Restart the Grafana server after changing the config file.
@ -74,11 +74,11 @@ When navigating to the grafana url (`http://localhost:8080/grafana` in the examp
1. The `root_url` setting in the Grafana config file does not match the parent url with subpath. This could happen if the root_url is commented out by mistake (`;` is used for commenting out a line in .ini files):
`; root_url = %(protocol)s://%(domain)s:/grafana`
`; root_url = %(protocol)s://%(domain)s/grafana/`
2. or if the subpath in the `root_url` setting does not match the subpath used in the pattern in the Inbound Rule in IIS:
`root_url = %(protocol)s://%(domain)s:/grafana`
`root_url = %(protocol)s://%(domain)s/grafana/`
pattern in Inbound Rule: `wrongsubpath(/)?(.*)`

View File

@ -26,9 +26,14 @@ import (
"github.com/grafana/grafana/pkg/middleware"
"github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/plugins"
"github.com/grafana/grafana/pkg/registry"
"github.com/grafana/grafana/pkg/setting"
)
func init() {
registry.RegisterService(&HTTPServer{})
}
type HTTPServer struct {
log log.Logger
macaron *macaron.Macaron
@ -41,12 +46,14 @@ type HTTPServer struct {
Bus bus.Bus `inject:""`
}
func (hs *HTTPServer) Init() {
func (hs *HTTPServer) Init() error {
hs.log = log.New("http.server")
hs.cache = gocache.New(5*time.Minute, 10*time.Minute)
return nil
}
func (hs *HTTPServer) Start(ctx context.Context) error {
func (hs *HTTPServer) Run(ctx context.Context) error {
var err error
hs.context = ctx
@ -57,17 +64,18 @@ func (hs *HTTPServer) Start(ctx context.Context) error {
hs.streamManager.Run(ctx)
listenAddr := fmt.Sprintf("%s:%s", setting.HttpAddr, setting.HttpPort)
hs.log.Info("Initializing HTTP Server", "address", listenAddr, "protocol", setting.Protocol, "subUrl", setting.AppSubUrl, "socket", setting.SocketPath)
hs.log.Info("HTTP Server Listen", "address", listenAddr, "protocol", setting.Protocol, "subUrl", setting.AppSubUrl, "socket", setting.SocketPath)
hs.httpSrv = &http.Server{Addr: listenAddr, Handler: hs.macaron}
// handle http shutdown on server context done
go func() {
<-ctx.Done()
// Hacky fix for race condition between ListenAndServe and Shutdown
time.Sleep(time.Millisecond * 100)
if err := hs.httpSrv.Shutdown(context.Background()); err != nil {
hs.log.Error("Failed to shutdown server", "error", err)
}
hs.log.Info("Stopped HTTP Server")
}()
switch setting.Protocol {
@ -106,12 +114,6 @@ func (hs *HTTPServer) Start(ctx context.Context) error {
return err
}
func (hs *HTTPServer) Shutdown(ctx context.Context) error {
err := hs.httpSrv.Shutdown(ctx)
hs.log.Info("Stopped HTTP server")
return err
}
func (hs *HTTPServer) listenAndServeTLS(certfile, keyfile string) error {
if certfile == "" {
return fmt.Errorf("cert_file cannot be empty when using HTTPS")
@ -172,6 +174,7 @@ func (hs *HTTPServer) newMacaron() *macaron.Macaron {
hs.mapStatic(m, route.Directory, "", pluginRoute)
}
hs.mapStatic(m, setting.StaticRootPath, "build", "public/build")
hs.mapStatic(m, setting.StaticRootPath, "", "public")
hs.mapStatic(m, setting.StaticRootPath, "robots.txt", "robots.txt")
@ -239,6 +242,12 @@ func (hs *HTTPServer) mapStatic(m *macaron.Macaron, rootDir string, dir string,
c.Resp.Header().Set("Cache-Control", "public, max-age=3600")
}
if prefix == "public/build" {
headers = func(c *macaron.Context) {
c.Resp.Header().Set("Cache-Control", "public, max-age=31536000")
}
}
if setting.Env == setting.DEV {
headers = func(c *macaron.Context) {
c.Resp.Header().Set("Cache-Control", "max-age=0, must-revalidate, no-cache")

View File

@ -40,7 +40,6 @@ var enterprise string
var configFile = flag.String("config", "", "path to config file")
var homePath = flag.String("homepath", "", "path to grafana install/home path, defaults to working directory")
var pidFile = flag.String("pidfile", "", "path to pid file")
var exitChan = make(chan int)
func main() {
v := flag.Bool("v", false, "prints current version and exits")
@ -82,29 +81,20 @@ func main() {
setting.Enterprise, _ = strconv.ParseBool(enterprise)
metrics.M_Grafana_Version.WithLabelValues(version).Set(1)
shutdownCompleted := make(chan int)
server := NewGrafanaServer()
go listenToSystemSignals(server, shutdownCompleted)
go listenToSystemSignals(server)
go func() {
code := 0
if err := server.Start(); err != nil {
log.Error2("Startup failed", "error", err)
code = 1
}
err := server.Run()
exitChan <- code
}()
code := <-shutdownCompleted
log.Info2("Grafana shutdown completed.", "code", code)
trace.Stop()
log.Close()
os.Exit(code)
server.Exit(err)
}
func listenToSystemSignals(server *GrafanaServerImpl, shutdownCompleted chan int) {
var code int
func listenToSystemSignals(server *GrafanaServerImpl) {
signalChan := make(chan os.Signal, 1)
ignoreChan := make(chan os.Signal, 1)
@ -113,12 +103,6 @@ func listenToSystemSignals(server *GrafanaServerImpl, shutdownCompleted chan int
select {
case sig := <-signalChan:
trace.Stop() // Stops trace if profiling has been enabled
server.Shutdown(0, fmt.Sprintf("system signal: %s", sig))
shutdownCompleted <- 0
case code = <-exitChan:
trace.Stop() // Stops trace if profiling has been enabled
server.Shutdown(code, "startup error")
shutdownCompleted <- code
server.Shutdown(fmt.Sprintf("System signal: %s", sig))
}
}

View File

@ -17,14 +17,12 @@ import (
"github.com/grafana/grafana/pkg/middleware"
"github.com/grafana/grafana/pkg/registry"
"github.com/grafana/grafana/pkg/services/dashboards"
"github.com/grafana/grafana/pkg/services/provisioning"
"golang.org/x/sync/errgroup"
"github.com/grafana/grafana/pkg/api"
"github.com/grafana/grafana/pkg/log"
"github.com/grafana/grafana/pkg/login"
"github.com/grafana/grafana/pkg/metrics"
"github.com/grafana/grafana/pkg/services/sqlstore"
"github.com/grafana/grafana/pkg/setting"
@ -33,10 +31,12 @@ import (
// self registering services
_ "github.com/grafana/grafana/pkg/extensions"
_ "github.com/grafana/grafana/pkg/metrics"
_ "github.com/grafana/grafana/pkg/plugins"
_ "github.com/grafana/grafana/pkg/services/alerting"
_ "github.com/grafana/grafana/pkg/services/cleanup"
_ "github.com/grafana/grafana/pkg/services/notifications"
_ "github.com/grafana/grafana/pkg/services/provisioning"
_ "github.com/grafana/grafana/pkg/services/search"
)
@ -54,17 +54,19 @@ func NewGrafanaServer() *GrafanaServerImpl {
}
type GrafanaServerImpl struct {
context context.Context
shutdownFn context.CancelFunc
childRoutines *errgroup.Group
log log.Logger
cfg *setting.Cfg
context context.Context
shutdownFn context.CancelFunc
childRoutines *errgroup.Group
log log.Logger
cfg *setting.Cfg
shutdownReason string
shutdownInProgress bool
RouteRegister api.RouteRegister `inject:""`
HttpServer *api.HTTPServer `inject:""`
}
func (g *GrafanaServerImpl) Start() error {
func (g *GrafanaServerImpl) Run() error {
g.loadConfiguration()
g.writePIDFile()
@ -72,14 +74,9 @@ func (g *GrafanaServerImpl) Start() error {
sqlstore.NewEngine() // TODO: this should return an error
sqlstore.EnsureAdminUser()
metrics.Init(g.cfg.Raw)
login.Init()
social.NewOAuthService()
if err := provisioning.Init(g.context, setting.HomePath, g.cfg.Raw); err != nil {
return fmt.Errorf("Failed to provision Grafana from config. error: %v", err)
}
tracingCloser, err := tracing.Init(g.cfg.Raw)
if err != nil {
return fmt.Errorf("Tracing settings is not valid. error: %v", err)
@ -91,7 +88,6 @@ func (g *GrafanaServerImpl) Start() error {
serviceGraph.Provide(&inject.Object{Value: g.cfg})
serviceGraph.Provide(&inject.Object{Value: dashboards.NewProvisioningService()})
serviceGraph.Provide(&inject.Object{Value: api.NewRouteRegister(middleware.RequestMetrics, middleware.RequestTracing)})
serviceGraph.Provide(&inject.Object{Value: api.HTTPServer{}})
// self registered services
services := registry.GetServices()
@ -117,7 +113,7 @@ func (g *GrafanaServerImpl) Start() error {
g.log.Info("Initializing " + reflect.TypeOf(service).Elem().Name())
if err := service.Init(); err != nil {
return fmt.Errorf("Service init failed %v", err)
return fmt.Errorf("Service init failed: %v", err)
}
}
@ -133,14 +129,31 @@ func (g *GrafanaServerImpl) Start() error {
}
g.childRoutines.Go(func() error {
// Skip starting new service when shutting down
// Can happen when service stop/return during startup
if g.shutdownInProgress {
return nil
}
err := service.Run(g.context)
g.log.Info("Stopped "+reflect.TypeOf(service).Elem().Name(), "reason", err)
// If error is not canceled then the service crashed
if err != context.Canceled && err != nil {
g.log.Error("Stopped "+reflect.TypeOf(service).Elem().Name(), "reason", err)
} else {
g.log.Info("Stopped "+reflect.TypeOf(service).Elem().Name(), "reason", err)
}
// Mark that we are in shutdown mode
// So more services are not started
g.shutdownInProgress = true
return err
})
}
sendSystemdNotification("READY=1")
return g.startHttpServer()
return g.childRoutines.Wait()
}
func (g *GrafanaServerImpl) loadConfiguration() {
@ -159,28 +172,29 @@ func (g *GrafanaServerImpl) loadConfiguration() {
g.cfg.LogConfigSources()
}
func (g *GrafanaServerImpl) startHttpServer() error {
g.HttpServer.Init()
err := g.HttpServer.Start(g.context)
if err != nil {
return fmt.Errorf("Fail to start server. error: %v", err)
}
return nil
}
func (g *GrafanaServerImpl) Shutdown(code int, reason string) {
g.log.Info("Shutdown started", "code", code, "reason", reason)
func (g *GrafanaServerImpl) Shutdown(reason string) {
g.log.Info("Shutdown started", "reason", reason)
g.shutdownReason = reason
g.shutdownInProgress = true
// call cancel func on root context
g.shutdownFn()
// wait for child routines
if err := g.childRoutines.Wait(); err != nil && err != context.Canceled {
g.log.Error("Server shutdown completed", "error", err)
g.childRoutines.Wait()
}
func (g *GrafanaServerImpl) Exit(reason error) {
// default exit code is 1
code := 1
if reason == context.Canceled && g.shutdownReason != "" {
reason = fmt.Errorf(g.shutdownReason)
code = 0
}
g.log.Error("Server shutdown", "reason", reason)
os.Exit(code)
}
func (g *GrafanaServerImpl) writePIDFile() {

View File

@ -106,6 +106,15 @@ func (f Float) String() string {
return fmt.Sprintf("%1.3f", f.Float64)
}
// FullString returns float as string in full precision
func (f Float) FullString() string {
if !f.Valid {
return "null"
}
return fmt.Sprintf("%f", f.Float64)
}
// SetValid changes this Float's value and also sets it to be non-null.
func (f *Float) SetValid(n float64) {
f.Float64 = n

View File

@ -53,6 +53,20 @@ func TestLdapAuther(t *testing.T) {
So(result, ShouldEqual, user1)
})
ldapAutherScenario("Given group match with different case", func(sc *scenarioContext) {
ldapAuther := NewLdapAuthenticator(&LdapServerConf{
LdapGroups: []*LdapGroupToOrgRole{
{GroupDN: "cn=users", OrgRole: "Admin"},
},
})
sc.userQueryReturns(user1)
result, err := ldapAuther.GetGrafanaUserFor(nil, &LdapUserInfo{MemberOf: []string{"CN=users"}})
So(err, ShouldBeNil)
So(result, ShouldEqual, user1)
})
ldapAutherScenario("Given no existing grafana user", func(sc *scenarioContext) {
ldapAuther := NewLdapAuthenticator(&LdapServerConf{
LdapGroups: []*LdapGroupToOrgRole{

View File

@ -1,5 +1,9 @@
package login
import (
"strings"
)
type LdapUserInfo struct {
DN string
FirstName string
@ -15,7 +19,7 @@ func (u *LdapUserInfo) isMemberOf(group string) bool {
}
for _, member := range u.MemberOf {
if member == group {
if strings.EqualFold(member, group) {
return true
}
}

View File

@ -1,38 +0,0 @@
package metrics
import (
"context"
ini "gopkg.in/ini.v1"
"github.com/grafana/grafana/pkg/log"
"github.com/grafana/grafana/pkg/metrics/graphitebridge"
)
var metricsLogger log.Logger = log.New("metrics")
type logWrapper struct {
logger log.Logger
}
func (lw *logWrapper) Println(v ...interface{}) {
lw.logger.Info("graphite metric bridge", v...)
}
func Init(file *ini.File) {
cfg := ReadSettings(file)
internalInit(cfg)
}
func internalInit(settings *MetricSettings) {
initMetricVars(settings)
if settings.GraphiteBridgeConfig != nil {
bridge, err := graphitebridge.NewBridge(settings.GraphiteBridgeConfig)
if err != nil {
metricsLogger.Error("failed to create graphite bridge", "error", err)
} else {
go bridge.Run(context.Background())
}
}
}

View File

@ -279,7 +279,7 @@ func init() {
}, []string{"version"})
}
func initMetricVars(settings *MetricSettings) {
func initMetricVars() {
prometheus.MustRegister(
M_Instance_Start,
M_Page_Status,
@ -316,28 +316,6 @@ func initMetricVars(settings *MetricSettings) {
M_StatTotal_Playlists,
M_Grafana_Version)
go instrumentationLoop(settings)
}
func instrumentationLoop(settings *MetricSettings) chan struct{} {
M_Instance_Start.Inc()
// set the total stats gauges before we publishing metrics
updateTotalStats()
onceEveryDayTick := time.NewTicker(time.Hour * 24)
everyMinuteTicker := time.NewTicker(time.Minute)
defer onceEveryDayTick.Stop()
defer everyMinuteTicker.Stop()
for {
select {
case <-onceEveryDayTick.C:
sendUsageStats()
case <-everyMinuteTicker.C:
updateTotalStats()
}
}
}
func updateTotalStats() {

71
pkg/metrics/service.go Normal file
View File

@ -0,0 +1,71 @@
package metrics
import (
"context"
"time"
"github.com/grafana/grafana/pkg/log"
"github.com/grafana/grafana/pkg/metrics/graphitebridge"
"github.com/grafana/grafana/pkg/registry"
"github.com/grafana/grafana/pkg/setting"
)
var metricsLogger log.Logger = log.New("metrics")
type logWrapper struct {
logger log.Logger
}
func (lw *logWrapper) Println(v ...interface{}) {
lw.logger.Info("graphite metric bridge", v...)
}
func init() {
registry.RegisterService(&InternalMetricsService{})
initMetricVars()
}
type InternalMetricsService struct {
Cfg *setting.Cfg `inject:""`
enabled bool
intervalSeconds int64
graphiteCfg *graphitebridge.Config
}
func (im *InternalMetricsService) Init() error {
return im.readSettings()
}
func (im *InternalMetricsService) Run(ctx context.Context) error {
// Start Graphite Bridge
if im.graphiteCfg != nil {
bridge, err := graphitebridge.NewBridge(im.graphiteCfg)
if err != nil {
metricsLogger.Error("failed to create graphite bridge", "error", err)
} else {
go bridge.Run(ctx)
}
}
M_Instance_Start.Inc()
// set the total stats gauges before we publishing metrics
updateTotalStats()
onceEveryDayTick := time.NewTicker(time.Hour * 24)
everyMinuteTicker := time.NewTicker(time.Minute)
defer onceEveryDayTick.Stop()
defer everyMinuteTicker.Stop()
for {
select {
case <-onceEveryDayTick.C:
sendUsageStats()
case <-everyMinuteTicker.C:
updateTotalStats()
case <-ctx.Done():
return ctx.Err()
}
}
}

View File

@ -1,67 +1,53 @@
package metrics
import (
"fmt"
"strings"
"time"
"github.com/grafana/grafana/pkg/metrics/graphitebridge"
"github.com/grafana/grafana/pkg/setting"
"github.com/prometheus/client_golang/prometheus"
ini "gopkg.in/ini.v1"
)
type MetricSettings struct {
Enabled bool
IntervalSeconds int64
GraphiteBridgeConfig *graphitebridge.Config
}
func ReadSettings(file *ini.File) *MetricSettings {
var settings = &MetricSettings{
Enabled: false,
func (im *InternalMetricsService) readSettings() error {
var section, err = im.Cfg.Raw.GetSection("metrics")
if err != nil {
return fmt.Errorf("Unable to find metrics config section %v", err)
}
var section, err = file.GetSection("metrics")
if err != nil {
metricsLogger.Crit("Unable to find metrics config section", "error", err)
im.enabled = section.Key("enabled").MustBool(false)
im.intervalSeconds = section.Key("interval_seconds").MustInt64(10)
if !im.enabled {
return nil
}
settings.Enabled = section.Key("enabled").MustBool(false)
settings.IntervalSeconds = section.Key("interval_seconds").MustInt64(10)
if !settings.Enabled {
return settings
if err := im.parseGraphiteSettings(); err != nil {
return fmt.Errorf("Unable to parse metrics graphite section, %v", err)
}
cfg, err := parseGraphiteSettings(settings, file)
if err != nil {
metricsLogger.Crit("Unable to parse metrics graphite section", "error", err)
return nil
}
settings.GraphiteBridgeConfig = cfg
return settings
return nil
}
func parseGraphiteSettings(settings *MetricSettings, file *ini.File) (*graphitebridge.Config, error) {
graphiteSection, err := setting.Raw.GetSection("metrics.graphite")
func (im *InternalMetricsService) parseGraphiteSettings() error {
graphiteSection, err := im.Cfg.Raw.GetSection("metrics.graphite")
if err != nil {
return nil, nil
return nil
}
address := graphiteSection.Key("address").String()
if address == "" {
return nil, nil
return nil
}
cfg := &graphitebridge.Config{
bridgeCfg := &graphitebridge.Config{
URL: address,
Prefix: graphiteSection.Key("prefix").MustString("prod.grafana.%(instance_name)s"),
CountersAsDelta: true,
Gatherer: prometheus.DefaultGatherer,
Interval: time.Duration(settings.IntervalSeconds) * time.Second,
Interval: time.Duration(im.intervalSeconds) * time.Second,
Timeout: 10 * time.Second,
Logger: &logWrapper{logger: metricsLogger},
ErrorHandling: graphitebridge.ContinueOnError,
@ -74,6 +60,8 @@ func parseGraphiteSettings(settings *MetricSettings, file *ini.File) (*graphiteb
prefix = "prod.grafana.%(instance_name)s."
}
cfg.Prefix = strings.Replace(prefix, "%(instance_name)s", safeInstanceName, -1)
return cfg, nil
bridgeCfg.Prefix = strings.Replace(prefix, "%(instance_name)s", safeInstanceName, -1)
im.graphiteCfg = bridgeCfg
return nil
}

View File

@ -4,6 +4,7 @@ import (
"fmt"
"net"
"net/mail"
"reflect"
"strings"
"time"
@ -111,6 +112,16 @@ func initContextWithAuthProxy(ctx *m.ReqContext, orgID int64) bool {
return true
}
for _, field := range []string{"Name", "Email", "Login"} {
if setting.AuthProxyHeaders[field] == "" {
continue
}
if val := ctx.Req.Header.Get(setting.AuthProxyHeaders[field]); val != "" {
reflect.ValueOf(extUser).Elem().FieldByName(field).SetString(val)
}
}
// add/update user in grafana
cmd := &m.UpsertUserCommand{
ReqContext: ctx,

View File

@ -19,12 +19,13 @@ type SendEmailCommandSync struct {
}
type SendWebhookSync struct {
Url string
User string
Password string
Body string
HttpMethod string
HttpHeader map[string]string
Url string
User string
Password string
Body string
HttpMethod string
HttpHeader map[string]string
ContentType string
}
type SendResetPasswordEmailCommand struct {

View File

@ -16,7 +16,7 @@ import (
"golang.org/x/sync/errgroup"
)
type Engine struct {
type AlertingService struct {
execQueue chan *Job
//clock clock.Clock
ticker *Ticker
@ -28,20 +28,20 @@ type Engine struct {
}
func init() {
registry.RegisterService(&Engine{})
registry.RegisterService(&AlertingService{})
}
func NewEngine() *Engine {
e := &Engine{}
func NewEngine() *AlertingService {
e := &AlertingService{}
e.Init()
return e
}
func (e *Engine) IsDisabled() bool {
func (e *AlertingService) IsDisabled() bool {
return !setting.AlertingEnabled || !setting.ExecuteAlerts
}
func (e *Engine) Init() error {
func (e *AlertingService) Init() error {
e.ticker = NewTicker(time.Now(), time.Second*0, clock.New())
e.execQueue = make(chan *Job, 1000)
e.scheduler = NewScheduler()
@ -52,7 +52,7 @@ func (e *Engine) Init() error {
return nil
}
func (e *Engine) Run(ctx context.Context) error {
func (e *AlertingService) Run(ctx context.Context) error {
alertGroup, ctx := errgroup.WithContext(ctx)
alertGroup.Go(func() error { return e.alertingTicker(ctx) })
alertGroup.Go(func() error { return e.runJobDispatcher(ctx) })
@ -61,7 +61,7 @@ func (e *Engine) Run(ctx context.Context) error {
return err
}
func (e *Engine) alertingTicker(grafanaCtx context.Context) error {
func (e *AlertingService) alertingTicker(grafanaCtx context.Context) error {
defer func() {
if err := recover(); err != nil {
e.log.Error("Scheduler Panic: stopping alertingTicker", "error", err, "stack", log.Stack(1))
@ -86,7 +86,7 @@ func (e *Engine) alertingTicker(grafanaCtx context.Context) error {
}
}
func (e *Engine) runJobDispatcher(grafanaCtx context.Context) error {
func (e *AlertingService) runJobDispatcher(grafanaCtx context.Context) error {
dispatcherGroup, alertCtx := errgroup.WithContext(grafanaCtx)
for {
@ -106,7 +106,7 @@ var (
alertMaxAttempts = 3
)
func (e *Engine) processJobWithRetry(grafanaCtx context.Context, job *Job) error {
func (e *AlertingService) processJobWithRetry(grafanaCtx context.Context, job *Job) error {
defer func() {
if err := recover(); err != nil {
e.log.Error("Alert Panic", "error", err, "stack", log.Stack(1))
@ -141,7 +141,7 @@ func (e *Engine) processJobWithRetry(grafanaCtx context.Context, job *Job) error
}
}
func (e *Engine) endJob(err error, cancelChan chan context.CancelFunc, job *Job) error {
func (e *AlertingService) endJob(err error, cancelChan chan context.CancelFunc, job *Job) error {
job.Running = false
close(cancelChan)
for cancelFn := range cancelChan {
@ -150,7 +150,7 @@ func (e *Engine) endJob(err error, cancelChan chan context.CancelFunc, job *Job)
return err
}
func (e *Engine) processJob(attemptID int, attemptChan chan int, cancelChan chan context.CancelFunc, job *Job) {
func (e *AlertingService) processJob(attemptID int, attemptChan chan int, cancelChan chan context.CancelFunc, job *Job) {
defer func() {
if err := recover(); err != nil {
e.log.Error("Alert Panic", "error", err, "stack", log.Stack(1))

View File

@ -0,0 +1,173 @@
package notifiers
import (
"bytes"
"io"
"mime/multipart"
"os"
"strconv"
"strings"
"github.com/grafana/grafana/pkg/bus"
"github.com/grafana/grafana/pkg/components/simplejson"
"github.com/grafana/grafana/pkg/log"
m "github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/services/alerting"
"github.com/grafana/grafana/pkg/setting"
)
func init() {
alerting.RegisterNotifier(&alerting.NotifierPlugin{
Type: "discord",
Name: "Discord",
Description: "Sends notifications to Discord",
Factory: NewDiscordNotifier,
OptionsTemplate: `
<h3 class="page-heading">Discord settings</h3>
<div class="gf-form">
<span class="gf-form-label width-14">Webhook URL</span>
<input type="text" required class="gf-form-input max-width-22" ng-model="ctrl.model.settings.url" placeholder="Discord webhook URL"></input>
</div>
`,
})
}
func NewDiscordNotifier(model *m.AlertNotification) (alerting.Notifier, error) {
url := model.Settings.Get("url").MustString()
if url == "" {
return nil, alerting.ValidationError{Reason: "Could not find webhook url property in settings"}
}
return &DiscordNotifier{
NotifierBase: NewNotifierBase(model.Id, model.IsDefault, model.Name, model.Type, model.Settings),
WebhookURL: url,
log: log.New("alerting.notifier.discord"),
}, nil
}
type DiscordNotifier struct {
NotifierBase
WebhookURL string
log log.Logger
}
func (this *DiscordNotifier) Notify(evalContext *alerting.EvalContext) error {
this.log.Info("Sending alert notification to", "webhook_url", this.WebhookURL)
ruleUrl, err := evalContext.GetRuleUrl()
if err != nil {
this.log.Error("Failed get rule link", "error", err)
return err
}
bodyJSON := simplejson.New()
bodyJSON.Set("username", "Grafana")
fields := make([]map[string]interface{}, 0)
for _, evt := range evalContext.EvalMatches {
fields = append(fields, map[string]interface{}{
"name": evt.Metric,
"value": evt.Value.FullString(),
"inline": true,
})
}
footer := map[string]interface{}{
"text": "Grafana v" + setting.BuildVersion,
"icon_url": "https://grafana.com/assets/img/fav32.png",
}
color, _ := strconv.ParseInt(strings.TrimLeft(evalContext.GetStateModel().Color, "#"), 16, 0)
embed := simplejson.New()
embed.Set("title", evalContext.GetNotificationTitle())
//Discord takes integer for color
embed.Set("color", color)
embed.Set("url", ruleUrl)
embed.Set("description", evalContext.Rule.Message)
embed.Set("type", "rich")
embed.Set("fields", fields)
embed.Set("footer", footer)
var image map[string]interface{}
var embeddedImage = false
if evalContext.ImagePublicUrl != "" {
image = map[string]interface{}{
"url": evalContext.ImagePublicUrl,
}
embed.Set("image", image)
} else {
image = map[string]interface{}{
"url": "attachment://graph.png",
}
embed.Set("image", image)
embeddedImage = true
}
bodyJSON.Set("embeds", []interface{}{embed})
json, _ := bodyJSON.MarshalJSON()
content_type := "application/json"
var body []byte
if embeddedImage {
var b bytes.Buffer
w := multipart.NewWriter(&b)
f, err := os.Open(evalContext.ImageOnDiskPath)
if err != nil {
this.log.Error("Can't open graph file", err)
return err
}
defer f.Close()
fw, err := w.CreateFormField("payload_json")
if err != nil {
return err
}
if _, err = fw.Write([]byte(string(json))); err != nil {
return err
}
fw, err = w.CreateFormFile("file", "graph.png")
if err != nil {
return err
}
if _, err = io.Copy(fw, f); err != nil {
return err
}
w.Close()
body = b.Bytes()
content_type = w.FormDataContentType()
} else {
body = json
}
cmd := &m.SendWebhookSync{
Url: this.WebhookURL,
Body: string(body),
HttpMethod: "POST",
ContentType: content_type,
}
if err := bus.DispatchCtx(evalContext.Ctx, cmd); err != nil {
this.log.Error("Failed to send notification to Discord", "error", err)
return err
}
return nil
}

View File

@ -0,0 +1,52 @@
package notifiers
import (
"testing"
"github.com/grafana/grafana/pkg/components/simplejson"
m "github.com/grafana/grafana/pkg/models"
. "github.com/smartystreets/goconvey/convey"
)
func TestDiscordNotifier(t *testing.T) {
Convey("Telegram notifier tests", t, func() {
Convey("Parsing alert notification from settings", func() {
Convey("empty settings should return error", func() {
json := `{ }`
settingsJSON, _ := simplejson.NewJson([]byte(json))
model := &m.AlertNotification{
Name: "discord_testing",
Type: "discord",
Settings: settingsJSON,
}
_, err := NewDiscordNotifier(model)
So(err, ShouldNotBeNil)
})
Convey("settings should trigger incident", func() {
json := `
{
"url": "https://web.hook/"
}`
settingsJSON, _ := simplejson.NewJson([]byte(json))
model := &m.AlertNotification{
Name: "discord_testing",
Type: "discord",
Settings: settingsJSON,
}
not, err := NewDiscordNotifier(model)
discordNotifier := not.(*DiscordNotifier)
So(err, ShouldBeNil)
So(discordNotifier.Name, ShouldEqual, "discord_testing")
So(discordNotifier.Type, ShouldEqual, "discord")
So(discordNotifier.WebhookURL, ShouldEqual, "https://web.hook/")
})
})
})
}

View File

@ -104,12 +104,13 @@ func (ns *NotificationService) Run(ctx context.Context) error {
func (ns *NotificationService) SendWebhookSync(ctx context.Context, cmd *m.SendWebhookSync) error {
return ns.sendWebRequestSync(ctx, &Webhook{
Url: cmd.Url,
User: cmd.User,
Password: cmd.Password,
Body: cmd.Body,
HttpMethod: cmd.HttpMethod,
HttpHeader: cmd.HttpHeader,
Url: cmd.Url,
User: cmd.User,
Password: cmd.Password,
Body: cmd.Body,
HttpMethod: cmd.HttpMethod,
HttpHeader: cmd.HttpHeader,
ContentType: cmd.ContentType,
})
}

View File

@ -15,12 +15,13 @@ import (
)
type Webhook struct {
Url string
User string
Password string
Body string
HttpMethod string
HttpHeader map[string]string
Url string
User string
Password string
Body string
HttpMethod string
HttpHeader map[string]string
ContentType string
}
var netTransport = &http.Transport{
@ -48,8 +49,13 @@ func (ns *NotificationService) sendWebRequestSync(ctx context.Context, webhook *
return err
}
request.Header.Add("Content-Type", "application/json")
if webhook.ContentType == "" {
webhook.ContentType = "application/json"
}
request.Header.Add("Content-Type", webhook.ContentType)
request.Header.Add("User-Agent", "Grafana")
if webhook.User != "" && webhook.Password != "" {
request.Header.Add("Authorization", util.GetBasicAuthHeader(webhook.User, webhook.Password))
}

View File

@ -69,7 +69,7 @@ func (cr *configReader) readConfig() ([]*DashboardsAsConfig, error) {
parsedDashboards, err := cr.parseConfigs(file)
if err != nil {
return nil, err
}
if len(parsedDashboards) > 0 {

View File

@ -10,19 +10,16 @@ import (
type DashboardProvisioner struct {
cfgReader *configReader
log log.Logger
ctx context.Context
}
func Provision(ctx context.Context, configDirectory string) (*DashboardProvisioner, error) {
func NewDashboardProvisioner(configDirectory string) *DashboardProvisioner {
log := log.New("provisioning.dashboard")
d := &DashboardProvisioner{
cfgReader: &configReader{path: configDirectory, log: log},
log: log,
ctx: ctx,
}
err := d.Provision(ctx)
return d, err
return d
}
func (provider *DashboardProvisioner) Provision(ctx context.Context) error {

View File

@ -2,30 +2,40 @@ package provisioning
import (
"context"
"fmt"
"path"
"path/filepath"
"github.com/grafana/grafana/pkg/registry"
"github.com/grafana/grafana/pkg/services/provisioning/dashboards"
"github.com/grafana/grafana/pkg/services/provisioning/datasources"
ini "gopkg.in/ini.v1"
"github.com/grafana/grafana/pkg/setting"
)
func Init(ctx context.Context, homePath string, cfg *ini.File) error {
provisioningPath := makeAbsolute(cfg.Section("paths").Key("provisioning").String(), homePath)
func init() {
registry.RegisterService(&ProvisioningService{})
}
datasourcePath := path.Join(provisioningPath, "datasources")
type ProvisioningService struct {
Cfg *setting.Cfg `inject:""`
}
func (ps *ProvisioningService) Init() error {
datasourcePath := path.Join(ps.Cfg.ProvisioningPath, "datasources")
if err := datasources.Provision(datasourcePath); err != nil {
return fmt.Errorf("Datasource provisioning error: %v", err)
}
return nil
}
func (ps *ProvisioningService) Run(ctx context.Context) error {
dashboardPath := path.Join(ps.Cfg.ProvisioningPath, "dashboards")
dashProvisioner := dashboards.NewDashboardProvisioner(dashboardPath)
if err := dashProvisioner.Provision(ctx); err != nil {
return err
}
dashboardPath := path.Join(provisioningPath, "dashboards")
_, err := dashboards.Provision(ctx, dashboardPath)
return err
}
func makeAbsolute(path string, root string) string {
if filepath.IsAbs(path) {
return path
}
return filepath.Join(root, path)
<-ctx.Done()
return ctx.Err()
}

View File

@ -21,4 +21,9 @@ func addUserAuthMigrations(mg *Migrator) {
mg.AddMigration("create user auth table", NewAddTableMigration(userAuthV1))
// add indices
addTableIndicesMigrations(mg, "v1", userAuthV1)
mg.AddMigration("alter user_auth.auth_id to length 190", new(RawSqlMigration).
Sqlite("SELECT 0 WHERE 0;").
Postgres("ALTER TABLE user_auth ALTER COLUMN auth_id TYPE VARCHAR(190);").
Mysql("ALTER TABLE user_auth MODIFY auth_id VARCHAR(190);"))
}

View File

@ -123,7 +123,7 @@ func getEngine() (*xorm.Engine, error) {
}
cnnstr = fmt.Sprintf("%s:%s@%s(%s)/%s?collation=utf8mb4_unicode_ci&allowNativePasswords=true",
DbCfg.User, DbCfg.Pwd, protocol, DbCfg.Host, DbCfg.Name)
url.QueryEscape(DbCfg.User), url.QueryEscape(DbCfg.Pwd), protocol, DbCfg.Host, url.PathEscape(DbCfg.Name))
if DbCfg.SslMode == "true" || DbCfg.SslMode == "skip-verify" {
tlsCert, err := makeCert("custom", DbCfg)
@ -142,13 +142,17 @@ func getEngine() (*xorm.Engine, error) {
if len(fields) > 1 && len(strings.TrimSpace(fields[1])) > 0 {
port = fields[1]
}
if DbCfg.Pwd == "" {
DbCfg.Pwd = "''"
}
if DbCfg.User == "" {
DbCfg.User = "''"
}
cnnstr = fmt.Sprintf("user=%s password=%s host=%s port=%s dbname=%s sslmode=%s sslcert=%s sslkey=%s sslrootcert=%s", DbCfg.User, DbCfg.Pwd, host, port, DbCfg.Name, DbCfg.SslMode, DbCfg.ClientCertPath, DbCfg.ClientKeyPath, DbCfg.CaCertPath)
cnnstr = fmt.Sprintf("user='%s' password='%s' host='%s' port='%s' dbname='%s' sslmode='%s' sslcert='%s' sslkey='%s' sslrootcert='%s'",
strings.Replace(DbCfg.User, `'`, `\'`, -1),
strings.Replace(DbCfg.Pwd, `'`, `\'`, -1),
strings.Replace(host, `'`, `\'`, -1),
strings.Replace(port, `'`, `\'`, -1),
strings.Replace(DbCfg.Name, `'`, `\'`, -1),
strings.Replace(DbCfg.SslMode, `'`, `\'`, -1),
strings.Replace(DbCfg.ClientCertPath, `'`, `\'`, -1),
strings.Replace(DbCfg.ClientKeyPath, `'`, `\'`, -1),
strings.Replace(DbCfg.CaCertPath, `'`, `\'`, -1),
)
case "sqlite3":
if !filepath.IsAbs(DbCfg.Path) {
DbCfg.Path = filepath.Join(setting.DataPath, DbCfg.Path)

View File

@ -52,12 +52,11 @@ var (
ApplicationName string
// Paths
LogsPath string
HomePath string
DataPath string
PluginsPath string
ProvisioningPath string
CustomInitPath = "conf/custom.ini"
LogsPath string
HomePath string
DataPath string
PluginsPath string
CustomInitPath = "conf/custom.ini"
// Log settings.
LogModes []string
@ -125,6 +124,7 @@ var (
AuthProxyAutoSignUp bool
AuthProxyLdapSyncTtl int
AuthProxyWhitelist string
AuthProxyHeaders map[string]string
// Basic Auth
BasicAuthEnabled bool
@ -187,6 +187,9 @@ var (
type Cfg struct {
Raw *ini.File
// Paths
ProvisioningPath string
// SMTP email settings
Smtp SmtpSettings
@ -516,7 +519,7 @@ func (cfg *Cfg) Load(args *CommandLineArgs) error {
Env = iniFile.Section("").Key("app_mode").MustString("development")
InstanceName = iniFile.Section("").Key("instance_name").MustString("unknown_instance_name")
PluginsPath = makeAbsolute(iniFile.Section("paths").Key("plugins").String(), HomePath)
ProvisioningPath = makeAbsolute(iniFile.Section("paths").Key("provisioning").String(), HomePath)
cfg.ProvisioningPath = makeAbsolute(iniFile.Section("paths").Key("provisioning").String(), HomePath)
server := iniFile.Section("server")
AppUrl, AppSubUrl = parseAppUrlAndSubUrl(server)
@ -611,6 +614,14 @@ func (cfg *Cfg) Load(args *CommandLineArgs) error {
AuthProxyLdapSyncTtl = authProxy.Key("ldap_sync_ttl").MustInt()
AuthProxyWhitelist = authProxy.Key("whitelist").String()
AuthProxyHeaders = make(map[string]string)
for _, propertyAndHeader := range util.SplitString(authProxy.Key("headers").String()) {
split := strings.SplitN(propertyAndHeader, ":", 2)
if len(split) == 2 {
AuthProxyHeaders[split[0]] = split[1]
}
}
// basic auth
authBasic := iniFile.Section("auth.basic")
BasicAuthEnabled = authBasic.Key("enabled").MustBool(true)
@ -719,6 +730,6 @@ func (cfg *Cfg) LogConfigSources() {
logger.Info("Path Data", "path", DataPath)
logger.Info("Path Logs", "path", LogsPath)
logger.Info("Path Plugins", "path", PluginsPath)
logger.Info("Path Provisioning", "path", ProvisioningPath)
logger.Info("Path Provisioning", "path", cfg.ProvisioningPath)
logger.Info("App mode " + Env)
}

View File

@ -10,6 +10,7 @@ import Graph from './Graph';
import Table from './Table';
import { DatasourceSrv } from 'app/features/plugins/datasource_srv';
import { buildQueryOptions, ensureQueries, generateQueryKey, hasQuery } from './utils/query';
import { decodePathComponent } from 'app/core/utils/location_util';
function makeTimeSeriesList(dataList, options) {
return dataList.map((seriesData, index) => {
@ -38,6 +39,19 @@ function makeTimeSeriesList(dataList, options) {
});
}
function parseInitialQueries(initial) {
if (!initial) {
return [];
}
try {
const parsed = JSON.parse(decodePathComponent(initial));
return parsed.queries.map(q => q.query);
} catch (e) {
console.error(e);
return [];
}
}
interface IExploreState {
datasource: any;
datasourceError: any;
@ -58,6 +72,7 @@ export class Explore extends React.Component<any, IExploreState> {
constructor(props) {
super(props);
const initialQueries = parseInitialQueries(props.routeParams.initial);
this.state = {
datasource: null,
datasourceError: null,
@ -65,7 +80,7 @@ export class Explore extends React.Component<any, IExploreState> {
graphResult: null,
latency: 0,
loading: false,
queries: ensureQueries(),
queries: ensureQueries(initialQueries),
requestOptions: null,
showingGraph: true,
showingTable: true,
@ -77,7 +92,7 @@ export class Explore extends React.Component<any, IExploreState> {
const datasource = await this.props.datasourceSrv.get();
const testResult = await datasource.testDatasource();
if (testResult.status === 'success') {
this.setState({ datasource, datasourceError: null, datasourceLoading: false });
this.setState({ datasource, datasourceError: null, datasourceLoading: false }, () => this.handleSubmit());
} else {
this.setState({ datasource: null, datasourceError: testResult.message, datasourceLoading: false });
}

View File

@ -6,13 +6,16 @@ class QueryRow extends PureComponent<any, any> {
constructor(props) {
super(props);
this.state = {
query: '',
edited: false,
query: props.query || '',
};
}
handleChangeQuery = value => {
const { index, onChangeQuery } = this.props;
this.setState({ query: value });
const { query } = this.state;
const edited = query !== value;
this.setState({ edited, query: value });
if (onChangeQuery) {
onChangeQuery(value, index);
}
@ -41,6 +44,7 @@ class QueryRow extends PureComponent<any, any> {
render() {
const { request } = this.props;
const { edited, query } = this.state;
return (
<div className="query-row">
<div className="query-row-tools">
@ -52,7 +56,12 @@ class QueryRow extends PureComponent<any, any> {
</button>
</div>
<div className="query-field-wrapper">
<QueryField onPressEnter={this.handlePressEnter} onQueryChange={this.handleChangeQuery} request={request} />
<QueryField
initialQuery={edited ? null : query}
onPressEnter={this.handlePressEnter}
onQueryChange={this.handleChangeQuery}
request={request}
/>
</div>
</div>
);
@ -63,7 +72,9 @@ export default class QueryRows extends PureComponent<any, any> {
render() {
const { className = '', queries, ...handlers } = this.props;
return (
<div className={className}>{queries.map((q, index) => <QueryRow key={q.key} index={index} {...handlers} />)}</div>
<div className={className}>
{queries.map((q, index) => <QueryRow key={q.key} index={index} query={q.query} {...handlers} />)}
</div>
);
}
}

View File

@ -19,6 +19,7 @@ export class Analytics {
});
ga.l = +new Date();
ga('create', (<any>config).googleAnalyticsId, 'auto');
ga('set', 'anonymizeIp', true);
return ga;
}

View File

@ -3,6 +3,7 @@ import _ from 'lodash';
import coreModule from 'app/core/core_module';
import appEvents from 'app/core/app_events';
import { encodePathComponent } from 'app/core/utils/location_util';
import Mousetrap from 'mousetrap';
import 'mousetrap-global-bind';
@ -13,7 +14,7 @@ export class KeybindingSrv {
timepickerOpen = false;
/** @ngInject */
constructor(private $rootScope, private $location) {
constructor(private $rootScope, private $location, private datasourceSrv) {
// clear out all shortcuts on route change
$rootScope.$on('$routeChangeSuccess', () => {
Mousetrap.reset();
@ -176,6 +177,17 @@ export class KeybindingSrv {
}
});
this.bind('x', async () => {
if (dashboard.meta.focusPanelId) {
const panel = dashboard.getPanelById(dashboard.meta.focusPanelId);
const datasource = await this.datasourceSrv.get(panel.datasource);
if (datasource && datasource.supportsExplore) {
const exploreState = encodePathComponent(JSON.stringify(datasource.getExploreState(panel)));
this.$location.url(`/explore/${exploreState}`);
}
}
});
// delete panel
this.bind('p r', () => {
if (dashboard.meta.focusPanelId && dashboard.meta.canEdit) {

View File

@ -30,17 +30,17 @@ describe('file_export', () => {
it('should export points in proper order', () => {
let text = fileExport.convertSeriesListToCsv(ctx.seriesList, ctx.timeFormat);
const expectedText =
'Series;Time;Value\n' +
'series_1;1500026100;1\n' +
'series_1;1500026200;2\n' +
'series_1;1500026300;null\n' +
'series_1;1500026400;null\n' +
'series_1;1500026500;null\n' +
'series_1;1500026600;6\n' +
'series_2;1500026100;11\n' +
'series_2;1500026200;12\n' +
'series_2;1500026300;13\n' +
'series_2;1500026500;15\n';
'"Series";"Time";"Value"\r\n' +
'"series_1";"1500026100";1\r\n' +
'"series_1";"1500026200";2\r\n' +
'"series_1";"1500026300";null\r\n' +
'"series_1";"1500026400";null\r\n' +
'"series_1";"1500026500";null\r\n' +
'"series_1";"1500026600";6\r\n' +
'"series_2";"1500026100";11\r\n' +
'"series_2";"1500026200";12\r\n' +
'"series_2";"1500026300";13\r\n' +
'"series_2";"1500026500";15';
expect(text).toBe(expectedText);
});
@ -50,15 +50,76 @@ describe('file_export', () => {
it('should export points in proper order', () => {
let text = fileExport.convertSeriesListToCsvColumns(ctx.seriesList, ctx.timeFormat);
const expectedText =
'Time;series_1;series_2\n' +
'1500026100;1;11\n' +
'1500026200;2;12\n' +
'1500026300;null;13\n' +
'1500026400;null;null\n' +
'1500026500;null;15\n' +
'1500026600;6;null\n';
'"Time";"series_1";"series_2"\r\n' +
'"1500026100";1;11\r\n' +
'"1500026200";2;12\r\n' +
'"1500026300";null;13\r\n' +
'"1500026400";null;null\r\n' +
'"1500026500";null;15\r\n' +
'"1500026600";6;null';
expect(text).toBe(expectedText);
});
});
describe('when exporting table data to csv', () => {
it('should properly escape special characters and quote all string values', () => {
const inputTable = {
columns: [
{ title: 'integer_value' },
{ text: 'string_value' },
{ title: 'float_value' },
{ text: 'boolean_value' },
],
rows: [
[123, 'some_string', 1.234, true],
[0o765, 'some string with " in the middle', 1e-2, false],
[0o765, 'some string with "" in the middle', 1e-2, false],
[0o765, 'some string with """ in the middle', 1e-2, false],
[0o765, '"some string with " at the beginning', 1e-2, false],
[0o765, 'some string with " at the end"', 1e-2, false],
[0x123, 'some string with \n in the middle', 10.01, false],
[0b1011, 'some string with ; in the middle', -12.34, true],
[123, 'some string with ;; in the middle', -12.34, true],
],
};
const returnedText = fileExport.convertTableDataToCsv(inputTable, false);
const expectedText =
'"integer_value";"string_value";"float_value";"boolean_value"\r\n' +
'123;"some_string";1.234;true\r\n' +
'501;"some string with "" in the middle";0.01;false\r\n' +
'501;"some string with """" in the middle";0.01;false\r\n' +
'501;"some string with """""" in the middle";0.01;false\r\n' +
'501;"""some string with "" at the beginning";0.01;false\r\n' +
'501;"some string with "" at the end""";0.01;false\r\n' +
'291;"some string with \n in the middle";10.01;false\r\n' +
'11;"some string with ; in the middle";-12.34;true\r\n' +
'123;"some string with ;; in the middle";-12.34;true';
expect(returnedText).toBe(expectedText);
});
it('should decode HTML encoded characters', function() {
const inputTable = {
columns: [{ text: 'string_value' }],
rows: [
['&quot;&amp;&auml;'],
['<strong>&quot;some html&quot;</strong>'],
['<a href="http://something/index.html">some text</a>'],
],
};
const returnedText = fileExport.convertTableDataToCsv(inputTable, false);
const expectedText =
'"string_value"\r\n' +
'"""&ä"\r\n' +
'"<strong>""some html""</strong>"\r\n' +
'"<a href=""http://something/index.html"">some text</a>"';
expect(returnedText).toBe(expectedText);
});
});
});

View File

@ -101,38 +101,88 @@ describeValueFormat('d', 245, 100, 0, '35 week');
describeValueFormat('d', 2456, 10, 0, '6.73 year');
describe('date time formats', function() {
const epoch = 1505634997920;
const utcTime = moment.utc(epoch);
const browserTime = moment(epoch);
it('should format as iso date', function() {
var str = kbn.valueFormats.dateTimeAsIso(1505634997920, 1);
expect(str).toBe(moment(1505634997920).format('YYYY-MM-DD HH:mm:ss'));
var expected = browserTime.format('YYYY-MM-DD HH:mm:ss');
var actual = kbn.valueFormats.dateTimeAsIso(epoch);
expect(actual).toBe(expected);
});
it('should format as iso date (in UTC)', function() {
var expected = utcTime.format('YYYY-MM-DD HH:mm:ss');
var actual = kbn.valueFormats.dateTimeAsIso(epoch, true);
expect(actual).toBe(expected);
});
it('should format as iso date and skip date when today', function() {
var now = moment();
var str = kbn.valueFormats.dateTimeAsIso(now.valueOf(), 1);
expect(str).toBe(now.format('HH:mm:ss'));
var expected = now.format('HH:mm:ss');
var actual = kbn.valueFormats.dateTimeAsIso(now.valueOf(), false);
expect(actual).toBe(expected);
});
it('should format as iso date (in UTC) and skip date when today', function() {
var now = moment.utc();
var expected = now.format('HH:mm:ss');
var actual = kbn.valueFormats.dateTimeAsIso(now.valueOf(), true);
expect(actual).toBe(expected);
});
it('should format as US date', function() {
var str = kbn.valueFormats.dateTimeAsUS(1505634997920, 1);
expect(str).toBe(moment(1505634997920).format('MM/DD/YYYY h:mm:ss a'));
var expected = browserTime.format('MM/DD/YYYY h:mm:ss a');
var actual = kbn.valueFormats.dateTimeAsUS(epoch, false);
expect(actual).toBe(expected);
});
it('should format as US date (in UTC)', function() {
var expected = utcTime.format('MM/DD/YYYY h:mm:ss a');
var actual = kbn.valueFormats.dateTimeAsUS(epoch, true);
expect(actual).toBe(expected);
});
it('should format as US date and skip date when today', function() {
var now = moment();
var str = kbn.valueFormats.dateTimeAsUS(now.valueOf(), 1);
expect(str).toBe(now.format('h:mm:ss a'));
var expected = now.format('h:mm:ss a');
var actual = kbn.valueFormats.dateTimeAsUS(now.valueOf(), false);
expect(actual).toBe(expected);
});
it('should format as US date (in UTC) and skip date when today', function() {
var now = moment.utc();
var expected = now.format('h:mm:ss a');
var actual = kbn.valueFormats.dateTimeAsUS(now.valueOf(), true);
expect(actual).toBe(expected);
});
it('should format as from now with days', function() {
var daysAgo = moment().add(-7, 'd');
var str = kbn.valueFormats.dateTimeFromNow(daysAgo.valueOf(), 1);
expect(str).toBe('7 days ago');
var expected = '7 days ago';
var actual = kbn.valueFormats.dateTimeFromNow(daysAgo.valueOf(), false);
expect(actual).toBe(expected);
});
it('should format as from now with days (in UTC)', function() {
var daysAgo = moment.utc().add(-7, 'd');
var expected = '7 days ago';
var actual = kbn.valueFormats.dateTimeFromNow(daysAgo.valueOf(), true);
expect(actual).toBe(expected);
});
it('should format as from now with minutes', function() {
var daysAgo = moment().add(-2, 'm');
var str = kbn.valueFormats.dateTimeFromNow(daysAgo.valueOf(), 1);
expect(str).toBe('2 minutes ago');
var expected = '2 minutes ago';
var actual = kbn.valueFormats.dateTimeFromNow(daysAgo.valueOf(), false);
expect(actual).toBe(expected);
});
it('should format as from now with minutes (in UTC)', function() {
var daysAgo = moment.utc().add(-2, 'm');
var expected = '2 minutes ago';
var actual = kbn.valueFormats.dateTimeFromNow(daysAgo.valueOf(), true);
expect(actual).toBe(expected);
});
});

View File

@ -281,6 +281,20 @@ describe('TimeSeries', function() {
expect(series.zindex).toBe(2);
});
});
describe('override color', function() {
beforeEach(function() {
series.applySeriesOverrides([{ alias: 'test', color: '#112233' }]);
});
it('should set color', function() {
expect(series.color).toBe('#112233');
});
it('should set bars.fillColor', function() {
expect(series.bars.fillColor).toBe('#112233');
});
});
});
describe('value formatter', function() {

View File

@ -99,6 +99,7 @@ export default class TimeSeries {
this.alias = opts.alias;
this.aliasEscaped = _.escape(opts.alias);
this.color = opts.color;
this.bars = { fillColor: opts.color };
this.valueFormater = kbn.valueFormats.none;
this.stats = {};
this.legend = true;
@ -112,11 +113,11 @@ export default class TimeSeries {
dashLength: [],
};
this.points = {};
this.bars = {};
this.yaxis = 1;
this.zindex = 0;
this.nullPointMode = null;
delete this.stack;
delete this.bars.show;
for (var i = 0; i < overrides.length; i++) {
var override = overrides[i];
@ -168,7 +169,7 @@ export default class TimeSeries {
this.fillBelowTo = override.fillBelowTo;
}
if (override.color !== void 0) {
this.color = override.color;
this.setColor(override.color);
}
if (override.transform !== void 0) {
this.transform = override.transform;
@ -346,4 +347,9 @@ export default class TimeSeries {
return false;
}
setColor(color) {
this.color = color;
this.bars.fillColor = color;
}
}

View File

@ -1,59 +1,108 @@
import _ from 'lodash';
import { isBoolean, isNumber, sortedUniq, sortedIndexOf, unescape as htmlUnescaped } from 'lodash';
import moment from 'moment';
import { saveAs } from 'file-saver';
import { isNullOrUndefined } from 'util';
const DEFAULT_DATETIME_FORMAT = 'YYYY-MM-DDTHH:mm:ssZ';
const POINT_TIME_INDEX = 1;
const POINT_VALUE_INDEX = 0;
const END_COLUMN = ';';
const END_ROW = '\r\n';
const QUOTE = '"';
const EXPORT_FILENAME = 'grafana_data_export.csv';
function csvEscaped(text) {
if (!text) {
return text;
}
return text.split(QUOTE).join(QUOTE + QUOTE);
}
const domParser = new DOMParser();
function htmlDecoded(text) {
if (!text) {
return text;
}
const regexp = /&[^;]+;/g;
function htmlDecoded(value) {
const parsedDom = domParser.parseFromString(value, 'text/html');
return parsedDom.body.textContent;
}
return text.replace(regexp, htmlDecoded).replace(regexp, htmlDecoded);
}
function formatSpecialHeader(useExcelHeader) {
return useExcelHeader ? `sep=${END_COLUMN}${END_ROW}` : '';
}
function formatRow(row, addEndRowDelimiter = true) {
let text = '';
for (let i = 0; i < row.length; i += 1) {
if (isBoolean(row[i]) || isNullOrUndefined(row[i])) {
text += row[i];
} else if (isNumber(row[i])) {
text += row[i].toLocaleString();
} else {
text += `${QUOTE}${csvEscaped(htmlUnescaped(htmlDecoded(row[i])))}${QUOTE}`;
}
if (i < row.length - 1) {
text += END_COLUMN;
}
}
return addEndRowDelimiter ? text + END_ROW : text;
}
export function convertSeriesListToCsv(seriesList, dateTimeFormat = DEFAULT_DATETIME_FORMAT, excel = false) {
var text = (excel ? 'sep=;\n' : '') + 'Series;Time;Value\n';
_.each(seriesList, function(series) {
_.each(series.datapoints, function(dp) {
text +=
series.alias + ';' + moment(dp[POINT_TIME_INDEX]).format(dateTimeFormat) + ';' + dp[POINT_VALUE_INDEX] + '\n';
});
});
let text = formatSpecialHeader(excel) + formatRow(['Series', 'Time', 'Value']);
for (let seriesIndex = 0; seriesIndex < seriesList.length; seriesIndex += 1) {
for (let i = 0; i < seriesList[seriesIndex].datapoints.length; i += 1) {
text += formatRow(
[
seriesList[seriesIndex].alias,
moment(seriesList[seriesIndex].datapoints[i][POINT_TIME_INDEX]).format(dateTimeFormat),
seriesList[seriesIndex].datapoints[i][POINT_VALUE_INDEX],
],
i < seriesList[seriesIndex].datapoints.length - 1 || seriesIndex < seriesList.length - 1
);
}
}
return text;
}
export function exportSeriesListToCsv(seriesList, dateTimeFormat = DEFAULT_DATETIME_FORMAT, excel = false) {
var text = convertSeriesListToCsv(seriesList, dateTimeFormat, excel);
saveSaveBlob(text, 'grafana_data_export.csv');
let text = convertSeriesListToCsv(seriesList, dateTimeFormat, excel);
saveSaveBlob(text, EXPORT_FILENAME);
}
export function convertSeriesListToCsvColumns(seriesList, dateTimeFormat = DEFAULT_DATETIME_FORMAT, excel = false) {
let text = (excel ? 'sep=;\n' : '') + 'Time;';
// add header
_.each(seriesList, function(series) {
text += series.alias + ';';
});
text = text.substring(0, text.length - 1);
text += '\n';
let text =
formatSpecialHeader(excel) +
formatRow(
['Time'].concat(
seriesList.map(function(val) {
return val.alias;
})
)
);
// process data
seriesList = mergeSeriesByTime(seriesList);
var dataArr = [[]];
var sIndex = 1;
_.each(seriesList, function(series) {
var cIndex = 0;
dataArr.push([]);
_.each(series.datapoints, function(dp) {
dataArr[0][cIndex] = moment(dp[POINT_TIME_INDEX]).format(dateTimeFormat);
dataArr[sIndex][cIndex] = dp[POINT_VALUE_INDEX];
cIndex++;
});
sIndex++;
});
// make text
for (var i = 0; i < dataArr[0].length; i++) {
text += dataArr[0][i] + ';';
for (var j = 1; j < dataArr.length; j++) {
text += dataArr[j][i] + ';';
}
text = text.substring(0, text.length - 1);
text += '\n';
for (let i = 0; i < seriesList[0].datapoints.length; i += 1) {
const timestamp = moment(seriesList[0].datapoints[i][POINT_TIME_INDEX]).format(dateTimeFormat);
text += formatRow(
[timestamp].concat(
seriesList.map(function(series) {
return series.datapoints[i][POINT_VALUE_INDEX];
})
),
i < seriesList[0].datapoints.length - 1
);
}
return text;
@ -71,15 +120,15 @@ function mergeSeriesByTime(seriesList) {
timestamps.push(seriesPoints[j][POINT_TIME_INDEX]);
}
}
timestamps = _.sortedUniq(timestamps.sort());
timestamps = sortedUniq(timestamps.sort());
for (let i = 0; i < seriesList.length; i++) {
let seriesPoints = seriesList[i].datapoints;
let seriesTimestamps = _.map(seriesPoints, p => p[POINT_TIME_INDEX]);
let seriesTimestamps = seriesPoints.map(p => p[POINT_TIME_INDEX]);
let extendedSeries = [];
let pointIndex;
for (let j = 0; j < timestamps.length; j++) {
pointIndex = _.sortedIndexOf(seriesTimestamps, timestamps[j]);
pointIndex = sortedIndexOf(seriesTimestamps, timestamps[j]);
if (pointIndex !== -1) {
extendedSeries.push(seriesPoints[pointIndex]);
} else {
@ -93,27 +142,26 @@ function mergeSeriesByTime(seriesList) {
export function exportSeriesListToCsvColumns(seriesList, dateTimeFormat = DEFAULT_DATETIME_FORMAT, excel = false) {
let text = convertSeriesListToCsvColumns(seriesList, dateTimeFormat, excel);
saveSaveBlob(text, 'grafana_data_export.csv');
saveSaveBlob(text, EXPORT_FILENAME);
}
export function convertTableDataToCsv(table, excel = false) {
let text = formatSpecialHeader(excel);
// add headline
text += formatRow(table.columns.map(val => val.title || val.text));
// process data
for (let i = 0; i < table.rows.length; i += 1) {
text += formatRow(table.rows[i], i < table.rows.length - 1);
}
return text;
}
export function exportTableDataToCsv(table, excel = false) {
var text = excel ? 'sep=;\n' : '';
// add header
_.each(table.columns, function(column) {
text += (column.title || column.text) + ';';
});
text += '\n';
// process data
_.each(table.rows, function(row) {
_.each(row, function(value) {
text += value + ';';
});
text += '\n';
});
saveSaveBlob(text, 'grafana_data_export.csv');
let text = convertTableDataToCsv(table, excel);
saveSaveBlob(text, EXPORT_FILENAME);
}
export function saveSaveBlob(payload, fname) {
var blob = new Blob([payload], { type: 'text/csv;charset=utf-8' });
let blob = new Blob([payload], { type: 'text/csv;charset=utf-8;header=present;' });
saveAs(blob, fname);
}

View File

@ -816,8 +816,8 @@ kbn.valueFormats.timeticks = function(size, decimals, scaledDecimals) {
return kbn.valueFormats.s(size / 100, decimals, scaledDecimals);
};
kbn.valueFormats.dateTimeAsIso = function(epoch) {
var time = moment(epoch);
kbn.valueFormats.dateTimeAsIso = function(epoch, isUtc) {
var time = isUtc ? moment.utc(epoch) : moment(epoch);
if (moment().isSame(epoch, 'day')) {
return time.format('HH:mm:ss');
@ -825,8 +825,8 @@ kbn.valueFormats.dateTimeAsIso = function(epoch) {
return time.format('YYYY-MM-DD HH:mm:ss');
};
kbn.valueFormats.dateTimeAsUS = function(epoch) {
var time = moment(epoch);
kbn.valueFormats.dateTimeAsUS = function(epoch, isUtc) {
var time = isUtc ? moment.utc(epoch) : moment(epoch);
if (moment().isSame(epoch, 'day')) {
return time.format('h:mm:ss a');
@ -834,8 +834,9 @@ kbn.valueFormats.dateTimeAsUS = function(epoch) {
return time.format('MM/DD/YYYY h:mm:ss a');
};
kbn.valueFormats.dateTimeFromNow = function(epoch) {
return moment(epoch).fromNow();
kbn.valueFormats.dateTimeFromNow = function(epoch, isUtc) {
var time = isUtc ? moment.utc(epoch) : moment(epoch);
return time.fromNow();
};
///// FORMAT MENU /////

View File

@ -1,6 +1,11 @@
import config from 'app/core/config';
const _stripBaseFromUrl = url => {
// Slash encoding for angular location provider, see https://github.com/angular/angular.js/issues/10479
const SLASH = '<SLASH>';
export const decodePathComponent = (pc: string) => decodeURIComponent(pc).replace(new RegExp(SLASH, 'g'), '/');
export const encodePathComponent = (pc: string) => encodeURIComponent(pc.replace(/\//g, SLASH));
export const stripBaseFromUrl = url => {
const appSubUrl = config.appSubUrl;
const stripExtraChars = appSubUrl.endsWith('/') ? 1 : 0;
const urlWithoutBase =
@ -9,6 +14,4 @@ const _stripBaseFromUrl = url => {
return urlWithoutBase;
};
export default {
stripBaseFromUrl: _stripBaseFromUrl,
};
export default { stripBaseFromUrl };

View File

@ -100,7 +100,7 @@ export class DashboardSrv {
.catch(this.handleSaveDashboardError.bind(this, clone, options));
}
saveDashboard(options, clone) {
saveDashboard(options?, clone?) {
if (clone) {
this.setCurrent(this.create(clone, this.dash.meta));
}
@ -124,6 +124,10 @@ export class DashboardSrv {
return this.save(this.dash.getSaveModelClone(), options);
}
saveJSONDashboard(json: string) {
return this.save(JSON.parse(json), {});
}
showDashboardProvisionedModal() {
this.$rootScope.appEvent('show-modal', {
templateHtml: '<save-provisioned-dashboard-modal dismiss="dismiss()"></save-provisioned-dashboard-modal>',

View File

@ -87,12 +87,22 @@
<gf-dashboard-history dashboard="dashboard"></gf-dashboard-history>
</div>
<div class="dashboard-settings__content" ng-if="ctrl.viewId === 'view_json'" >
<h3 class="dashboard-settings__header">View JSON</h3>
<div class="dashboard-settings__content" ng-if="ctrl.viewId === 'dashboard_json'" >
<h3 class="dashboard-settings__header">JSON Model</h3>
<div class="dashboard-settings__subheader">
The JSON Model below is data structure that defines the dashboard. Including settings, panel settings & layout,
queries etc.
</div>
<div class="gf-form">
<code-editor content="ctrl.json" data-mode="json" data-max-lines=30 ></code-editor>
</div>
<div class="gf-form-button-row">
<button class="btn btn-success" ng-click="ctrl.saveDashboardJson()" ng-show="ctrl.canSave">
<i class="fa fa-save"></i> Save Changes
</button>
</div>
</div>
<div class="dashboard-settings__content" ng-if="ctrl.viewId === 'permissions'" >

View File

@ -17,7 +17,14 @@ export class SettingsCtrl {
hasUnsavedFolderChange: boolean;
/** @ngInject */
constructor(private $scope, private $location, private $rootScope, private backendSrv, private dashboardSrv) {
constructor(
private $scope,
private $route,
private $location,
private $rootScope,
private backendSrv,
private dashboardSrv
) {
// temp hack for annotations and variables editors
// that rely on inherited scope
$scope.dashboard = this.dashboard;
@ -93,8 +100,8 @@ export class SettingsCtrl {
}
this.sections.push({
title: 'View JSON',
id: 'view_json',
title: 'JSON Model',
id: 'dashboard_json',
icon: 'gicon gicon-json',
});
@ -137,6 +144,12 @@ export class SettingsCtrl {
this.dashboardSrv.saveDashboard();
}
saveDashboardJson() {
this.dashboardSrv.saveJSONDashboard(this.json).then(() => {
this.$route.reload();
});
}
onPostSave() {
this.hasUnsavedFolderChange = false;
}

View File

@ -15,7 +15,7 @@ function dashLinksContainer() {
}
/** @ngInject */
function dashLink($compile, linkSrv) {
function dashLink($compile, $sanitize, linkSrv) {
return {
restrict: 'E',
link: function(scope, elem) {
@ -49,10 +49,21 @@ function dashLink($compile, linkSrv) {
var linkInfo = linkSrv.getAnchorInfo(link);
span.text(linkInfo.title);
anchor.attr('href', linkInfo.href);
sanitizeAnchor();
// tooltip
elem.find('a').tooltip({
title: $sanitize(scope.link.tooltip),
html: true,
container: 'body',
});
}
function sanitizeAnchor() {
const anchorSanitized = $sanitize(anchor.parent().html());
anchor.parent().html(anchorSanitized);
}
// tooltip
elem.find('a').tooltip({ title: scope.link.tooltip, html: true, container: 'body' });
icon.attr('class', 'fa fa-fw ' + scope.link.icon);
anchor.attr('target', scope.link.target);

View File

@ -6,6 +6,7 @@ import { PanelCtrl } from 'app/features/panel/panel_ctrl';
import * as rangeUtil from 'app/core/utils/rangeutil';
import * as dateMath from 'app/core/utils/datemath';
import { encodePathComponent } from 'app/core/utils/location_util';
import { metricsTabDirective } from './metrics_tab';
@ -309,6 +310,24 @@ class MetricsPanelCtrl extends PanelCtrl {
this.refresh();
}
getAdditionalMenuItems() {
const items = [];
if (this.datasource.supportsExplore) {
items.push({
text: 'Explore',
click: 'ctrl.explore();',
icon: 'fa fa-fw fa-rocket',
shortcut: 'x',
});
}
return items;
}
explore() {
const exploreState = encodePathComponent(JSON.stringify(this.datasource.getExploreState(this.panel)));
this.$location.url(`/explore/${exploreState}`);
}
addQuery(target) {
target.refId = this.dashboard.getNextQueryLetter(this.panel);

View File

@ -22,6 +22,7 @@ export class PanelCtrl {
editorTabs: any;
$scope: any;
$injector: any;
$location: any;
$timeout: any;
fullscreen: boolean;
inspector: any;
@ -35,6 +36,7 @@ export class PanelCtrl {
constructor($scope, $injector) {
this.$injector = $injector;
this.$location = $injector.get('$location');
this.$scope = $scope;
this.$timeout = $injector.get('$timeout');
this.editorTabIndex = 0;
@ -161,6 +163,9 @@ export class PanelCtrl {
shortcut: 'p s',
});
// Additional items from sub-class
menu.push(...this.getAdditionalMenuItems());
let extendedMenu = this.getExtendedMenu();
menu.push({
text: 'More ...',
@ -209,6 +214,11 @@ export class PanelCtrl {
return menu;
}
// Override in sub-class to add items before extended menu
getAdditionalMenuItems() {
return [];
}
otherPanelInFullscreenMode() {
return this.dashboard.meta.fullscreen && !this.fullscreen;
}
@ -314,6 +324,7 @@ export class PanelCtrl {
}
var linkSrv = this.$injector.get('linkSrv');
var sanitize = this.$injector.get('$sanitize');
var templateSrv = this.$injector.get('templateSrv');
var interpolatedMarkdown = templateSrv.replace(markdown, this.panel.scopedVars);
var html = '<div class="markdown-html">';
@ -336,7 +347,8 @@ export class PanelCtrl {
html += '</ul>';
}
return html + '</div>';
html += '</div>';
return sanitize(html);
}
openInspector() {

View File

@ -136,6 +136,11 @@ describe('templateSrv', function() {
var target = _templateSrv.replace('this=${test:pipe}', {});
expect(target).toBe('this=value1|value2');
});
it('should replace ${test:pipe} with piped value and $test with globbed value', function() {
var target = _templateSrv.replace('${test:pipe},$test', {}, 'glob');
expect(target).toBe('value1|value2,{value1,value2}');
});
});
describe('variable with all option', function() {
@ -164,6 +169,11 @@ describe('templateSrv', function() {
var target = _templateSrv.replace('this.${test:glob}.filters', {});
expect(target).toBe('this.{value1,value2}.filters');
});
it('should replace ${test:pipe} with piped value and $test with globbed value', function() {
var target = _templateSrv.replace('${test:pipe},$test', {}, 'glob');
expect(target).toBe('value1|value2,{value1,value2}');
});
});
describe('variable with all option and custom value', function() {

View File

@ -74,6 +74,9 @@ export class TemplateSrv {
if (typeof value === 'string') {
return luceneEscape(value);
}
if (value instanceof Array && value.length === 0) {
return '__empty__';
}
var quotedValues = _.map(value, function(val) {
return '"' + luceneEscape(val) + '"';
});
@ -179,16 +182,16 @@ export class TemplateSrv {
return target;
}
var variable, systemValue, value;
var variable, systemValue, value, fmt;
this.regex.lastIndex = 0;
return target.replace(this.regex, (match, var1, var2, fmt2, var3, fmt3) => {
variable = this.index[var1 || var2 || var3];
format = fmt2 || fmt3 || format;
fmt = fmt2 || fmt3 || format;
if (scopedVars) {
value = scopedVars[var1 || var2 || var3];
if (value) {
return this.formatValue(value.value, format, variable);
return this.formatValue(value.value, fmt, variable);
}
}
@ -198,7 +201,7 @@ export class TemplateSrv {
systemValue = this.grafanaVariables[variable.current.value];
if (systemValue) {
return this.formatValue(systemValue, format, variable);
return this.formatValue(systemValue, fmt, variable);
}
value = variable.current.value;
@ -210,7 +213,7 @@ export class TemplateSrv {
}
}
var res = this.formatValue(value, format, variable);
var res = this.formatValue(value, fmt, variable);
return res;
});
}

View File

@ -395,6 +395,7 @@ export class ElasticDatasource {
}
if (query.find === 'terms') {
query.field = this.templateSrv.replace(query.field, {}, 'lucene');
query.query = this.templateSrv.replace(query.query || '*', {}, 'lucene');
return this.getTerms(query);
}

View File

@ -11,14 +11,23 @@ export default class ResponseParser {
return [];
}
var influxdb11format = query.toLowerCase().indexOf('show tag values') >= 0;
var res = {};
_.each(influxResults.series, serie => {
_.each(serie.values, value => {
if (_.isArray(value)) {
if (influxdb11format) {
addUnique(res, value[1] || value[0]);
// In general, there are 2 possible shapes for the returned value.
// The first one is a two-element array,
// where the first element is somewhat a metadata value:
// the tag name for SHOW TAG VALUES queries,
// the time field for SELECT queries, etc.
// The second shape is an one-element array,
// that is containing an immediate value.
// For example, SHOW FIELD KEYS queries return such shape.
// Note, pre-0.11 versions return
// the second shape for SHOW TAG VALUES queries
// (while the newer versions—first).
if (value[1] !== undefined) {
addUnique(res, value[1]);
} else {
addUnique(res, value[0]);
}
@ -29,7 +38,7 @@ export default class ResponseParser {
});
return _.map(res, value => {
return { text: value };
return { text: value.toString() };
});
}
}

View File

@ -85,6 +85,32 @@ describe('influxdb response parser', () => {
});
});
describe('SELECT response', () => {
var query = 'SELECT "usage_iowait" FROM "cpu" LIMIT 10';
var response = {
results: [
{
series: [
{
name: 'cpu',
columns: ['time', 'usage_iowait'],
values: [[1488465190006040638, 0.0], [1488465190006040638, 15.0], [1488465190006040638, 20.2]],
},
],
},
],
};
var result = parser.parse(query, response);
it('should return second column', () => {
expect(_.size(result)).toBe(3);
expect(result[0].text).toBe('0');
expect(result[1].text).toBe('15');
expect(result[2].text).toBe('20.2');
});
});
describe('SHOW FIELD response', () => {
var query = 'SHOW FIELD KEYS FROM "cpu"';
describe('response from 0.10.0', () => {

View File

@ -19,6 +19,7 @@ export class PrometheusDatasource {
type: string;
editorSrc: string;
name: string;
supportsExplore: boolean;
supportMetrics: boolean;
url: string;
directUrl: string;
@ -34,6 +35,7 @@ export class PrometheusDatasource {
this.type = 'prometheus';
this.editorSrc = 'app/features/prometheus/partials/query.editor.html';
this.name = instanceSettings.name;
this.supportsExplore = true;
this.supportMetrics = true;
this.url = instanceSettings.url;
this.directUrl = instanceSettings.directUrl;
@ -153,6 +155,7 @@ export class PrometheusDatasource {
end: end,
responseListLength: responseList.length,
responseIndex: index,
refId: activeTargets[index].refId,
};
this.resultTransformer.transform(result, response, transformerOptions);
@ -323,6 +326,21 @@ export class PrometheusDatasource {
});
}
getExploreState(panel) {
let state = {};
if (panel.targets) {
const queries = panel.targets.map(t => ({
query: this.templateSrv.replace(t.expr, {}, this.interpolateQueryExpr),
format: t.format,
}));
state = {
...state,
queries,
};
}
return state;
}
getPrometheusTime(date, roundUp) {
if (_.isString(date)) {
date = dateMath.parse(date, roundUp);

View File

@ -8,7 +8,7 @@ export class ResultTransformer {
let prometheusResult = response.data.data.result;
if (options.format === 'table') {
result.push(this.transformMetricDataToTable(prometheusResult, options.responseListLength, options.responseIndex));
result.push(this.transformMetricDataToTable(prometheusResult, options.responseListLength, options.refId));
} else if (options.format === 'heatmap') {
let seriesList = [];
prometheusResult.sort(sortSeriesByLabel);
@ -58,7 +58,7 @@ export class ResultTransformer {
return { target: metricLabel, datapoints: dps };
}
transformMetricDataToTable(md, resultCount: number, resultIndex: number) {
transformMetricDataToTable(md, resultCount: number, refId: string) {
var table = new TableModel();
var i, j;
var metricLabels = {};
@ -83,7 +83,7 @@ export class ResultTransformer {
metricLabels[label] = labelIndex + 1;
table.columns.push({ text: label });
});
let valueText = resultCount > 1 ? `Value #${String.fromCharCode(65 + resultIndex)}` : 'Value';
let valueText = resultCount > 1 ? `Value #${refId}` : 'Value';
table.columns.push({ text: valueText });
// Populate rows, set value to empty string when label not present.

View File

@ -47,6 +47,18 @@ describe('Prometheus Result Transformer', () => {
{ text: 'Value' },
]);
});
it('should column title include refId if response count is more than 2', () => {
var table = ctx.resultTransformer.transformMetricDataToTable(response.data.result, 2, 'B');
expect(table.type).toBe('table');
expect(table.columns).toEqual([
{ text: 'Time', type: 'time' },
{ text: '__name__' },
{ text: 'instance' },
{ text: 'job' },
{ text: 'Value #B' },
]);
});
});
describe('When resultFormat is table and instant = true', () => {

View File

@ -235,7 +235,7 @@ class GraphCtrl extends MetricsPanelCtrl {
}
changeSeriesColor(series, color) {
series.color = color;
series.setColor(color);
this.panel.aliasColors[series.alias] = series.color;
this.render();
}

View File

@ -12,7 +12,7 @@ class PluginListCtrl extends PanelCtrl {
panelDefaults = {};
/** @ngInject */
constructor($scope, $injector, private backendSrv, private $location) {
constructor($scope, $injector, private backendSrv) {
super($scope, $injector);
_.defaults(this.panel, this.panelDefaults);

View File

@ -77,7 +77,7 @@ class SingleStatCtrl extends MetricsPanelCtrl {
};
/** @ngInject */
constructor($scope, $injector, private $location, private linkSrv) {
constructor($scope, $injector, private linkSrv) {
super($scope, $injector);
_.defaults(this.panel, this.panelDefaults);
@ -308,7 +308,7 @@ class SingleStatCtrl extends MetricsPanelCtrl {
let formatFunc = kbn.valueFormats[this.panel.format];
data.value = lastPoint[1];
data.valueRounded = data.value;
data.valueFormatted = formatFunc(data.value, 0, 0);
data.valueFormatted = formatFunc(data.value, this.dashboard.isTimezoneUtc());
} else {
data.value = this.series[0].stats[this.panel.valueName];
data.flotpairs = this.series[0].flotpairs;

View File

@ -82,6 +82,19 @@ describe('SingleStatCtrl', function() {
});
});
singleStatScenario('showing last iso time instead of value (in UTC)', function(ctx) {
ctx.setup(function() {
ctx.data = [{ target: 'test.cpu1', datapoints: [[10, 12], [20, 1505634997920]] }];
ctx.ctrl.panel.valueName = 'last_time';
ctx.ctrl.panel.format = 'dateTimeAsIso';
ctx.setIsUtc(true);
});
it('should set formatted value', function() {
expect(ctx.data.valueFormatted).to.be(moment.utc(1505634997920).format('YYYY-MM-DD HH:mm:ss'));
});
});
singleStatScenario('showing last us time instead of value', function(ctx) {
ctx.setup(function() {
ctx.data = [{ target: 'test.cpu1', datapoints: [[10, 12], [20, 1505634997920]] }];
@ -99,6 +112,19 @@ describe('SingleStatCtrl', function() {
});
});
singleStatScenario('showing last us time instead of value (in UTC)', function(ctx) {
ctx.setup(function() {
ctx.data = [{ target: 'test.cpu1', datapoints: [[10, 12], [20, 1505634997920]] }];
ctx.ctrl.panel.valueName = 'last_time';
ctx.ctrl.panel.format = 'dateTimeAsUS';
ctx.setIsUtc(true);
});
it('should set formatted value', function() {
expect(ctx.data.valueFormatted).to.be(moment.utc(1505634997920).format('MM/DD/YYYY h:mm:ss a'));
});
});
singleStatScenario('showing last time from now instead of value', function(ctx) {
beforeEach(() => {
clock = sinon.useFakeTimers(epoch);
@ -124,6 +150,27 @@ describe('SingleStatCtrl', function() {
});
});
singleStatScenario('showing last time from now instead of value (in UTC)', function(ctx) {
beforeEach(() => {
clock = sinon.useFakeTimers(epoch);
});
ctx.setup(function() {
ctx.data = [{ target: 'test.cpu1', datapoints: [[10, 12], [20, 1505634997920]] }];
ctx.ctrl.panel.valueName = 'last_time';
ctx.ctrl.panel.format = 'dateTimeFromNow';
ctx.setIsUtc(true);
});
it('should set formatted value', function() {
expect(ctx.data.valueFormatted).to.be('2 days ago');
});
afterEach(() => {
clock.restore();
});
});
singleStatScenario('MainValue should use same number for decimals as displayed when checking thresholds', function(
ctx
) {

View File

@ -154,6 +154,11 @@ class TablePanelCtrl extends MetricsPanelCtrl {
this.render();
}
moveQuery(target, direction) {
super.moveQuery(target, direction);
super.refresh();
}
exportCsv() {
var scope = this.$scope.$new(true);
scope.tableData = this.renderer.render_values();

View File

@ -247,7 +247,7 @@ export class TableRenderer {
var scopedVars = this.renderRowVariables(rowIndex);
scopedVars['__cell'] = { value: value };
var cellLink = this.templateSrv.replace(column.style.linkUrl, scopedVars);
var cellLink = this.templateSrv.replace(column.style.linkUrl, scopedVars, encodeURIComponent);
var cellLinkTooltip = this.templateSrv.replace(column.style.linkTooltip, scopedVars);
var cellTarget = column.style.linkTargetBlank ? '_blank' : '';

View File

@ -29,6 +29,7 @@ export function reactContainer($route, $location, backendSrv: BackendSrv, dataso
const props = {
backendSrv: backendSrv,
datasourceSrv: datasourceSrv,
routeParams: $route.current.params,
};
ReactDOM.render(WrapInProvider(store, component, props), elem[0]);

View File

@ -111,7 +111,7 @@ export function setupAngularRoutes($routeProvider, $locationProvider) {
controller: 'FolderDashboardsCtrl',
controllerAs: 'ctrl',
})
.when('/explore', {
.when('/explore/:initial?', {
template: '<react-container />',
resolve: {
component: () => import(/* webpackChunkName: "explore" */ 'app/containers/Explore/Explore'),

View File

@ -1,4 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="800" height="500" viewBox="0 0 800 500">
<svg xmlns="http://www.w3.org/2000/svg" width="800" height="500" viewBox="0 0 800 500">
<metadata><?xpacket begin="" id="W5M0MpCehiHzreSzNTczkc9d"?>
<x:xmpmeta xmlns:x="adobe:ns:meta/" x:xmptk="Adobe XMP Core 5.6-c138 79.159824, 2016/09/14-01:09:01 ">
<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
@ -58,7 +58,6 @@
}
</style>
</defs>
<image id="Lager_1" data-name="Lager 1" width="800" height="500" xlink:href="data:img/png;base64,iVBORw0KGgoAAAANSUhEUgAAAyAAAAH0AQAAAADtO3TVAAAABGdBTUEAALGPC/xhBQAAACBjSFJNAAB6JgAAgIQAAPoAAACA6AAAdTAAAOpgAAA6mAAAF3CculE8AAAAAmJLR0QAAKqNIzIAAAAJcEhZcwAAFiUAABYlAUlSJPAAAAAHdElNRQfhCQ8OGxT9zSJNAAAAxUlEQVR42u3NMQEAAAwCIPuX1hTbBQVIH0QikUgkEolEIpFIJBKJRCKRSCQSiUQikUgkEolEIpFIJBKJRCKRSCQSiUQikUgkEolEIpFIJBKJRCKRSCQSiUQikUgkEolEIpFIJBKJRCKRSCQSiUQikUgkEolEIpFIJBKJRCKRSCQSiUQikUgkEolEIpFIJBKJRCKRSCQSiUQikUgkEolEIpFIJBKJRCKRSCQSiUQikUgkEolEIpFIJBKJRCKRSCQSiUQikVwZyReYD18j2sEAAAAASUVORK5CYII="/>
<path id="Form_3" data-name="Form 3" class="cls-1" d="M0,2V500H800"/>
<path id="Form_4" data-name="Form 4" class="cls-1" d="M160,2V500"/>
<path id="Form_5" data-name="Form 5" class="cls-1" d="M320,1V500"/>
@ -69,5 +68,5 @@
<path id="Form_10" data-name="Form 10" class="cls-1" d="M0,300H798"/>
<path id="Form_11" data-name="Form 11" class="cls-1" d="M0,400H800"/>
<path id="Form_12" data-name="Form 12" class="cls-2" d="M0,0C0,299.762,320.7,500,800,500"/>
<path id="Form_12_kopiera" data-name="Form 12 kopiera" class="cls-3" d="M800,500C320.7,500,0,299.762,0,0,0-234.869,0,500,0,500H800Z"/>
<path id="Form_12_kopiera" data-name="Form 12 kopiera" class="cls-3" d="M800,500C320.7,500,0,299.762,0,0V500H800Z"/>
</svg>

Before

Width:  |  Height:  |  Size: 4.4 KiB

After

Width:  |  Height:  |  Size: 3.7 KiB

View File

@ -53,6 +53,13 @@
margin-bottom: $spacer*2;
}
.dashboard-settings__subheader {
color: $text-muted;
font-style: italic;
position: relative;
top: -1.5rem;
}
.dashboard-settings__nav-item {
padding: 7px 12px;
color: $text-color;
@ -85,3 +92,7 @@
margin-bottom: 10px;
}
}
.dashboard-settings__json-save-button {
margin-top: $spacer;
}

View File

@ -102,5 +102,6 @@ $select-option-selected-bg: $dropdownLinkBackgroundActive;
.gf-form-input--form-dropdown-right {
.Select-menu-outer {
right: 0;
left: unset;
}
}

View File

@ -71,21 +71,29 @@
td {
padding: 1px;
}
button.btn-sm {
button {
@include buttonBackground($btn-inverse-bg, $btn-inverse-bg-hl);
font-size: $font-size-sm;
background-image: none;
border: none;
padding: 5px 11px;
color: $text-color;
&.active span {
color: $blue;
color: $query-blue;
font-weight: bold;
}
.text-info {
color: $orange;
font-weight: bold;
}
&.btn-sm {
font-size: $font-size-sm;
padding: 5px 11px;
}
&:hover {
color: $text-color-strong;
}
&[disabled] {
color: $text-color;
}
}
}
@ -103,10 +111,10 @@
}
.fa-chevron-left::before {
content: "\f053";
content: '\f053';
}
.fa-chevron-right::before {
content: "\f054";
content: '\f054';
}
.glyphicon-chevron-right {

View File

@ -44,7 +44,8 @@ div.flot-text {
padding: $panel-padding;
height: calc(100% - 27px);
position: relative;
overflow: hidden;
// Fixes scrolling on mobile devices
overflow: auto;
}
.panel-title-container {

View File

@ -1,14 +1,15 @@
import _ from 'lodash';
import config from 'app/core/config';
import * as dateMath from 'app/core/utils/datemath';
import {angularMocks, sinon} from '../lib/common';
import {PanelModel} from 'app/features/dashboard/panel_model';
import { angularMocks, sinon } from '../lib/common';
import { PanelModel } from 'app/features/dashboard/panel_model';
export function ControllerTestContext() {
var self = this;
this.datasource = {};
this.$element = {};
this.$sanitize = {};
this.annotationsSrv = {};
this.timeSrv = new TimeSrvStub();
this.templateSrv = new TemplateSrvStub();
@ -22,6 +23,7 @@ export function ControllerTestContext() {
};
},
};
this.isUtc = false;
this.providePhase = function(mocks) {
return angularMocks.module(function($provide) {
@ -30,6 +32,7 @@ export function ControllerTestContext() {
$provide.value('timeSrv', self.timeSrv);
$provide.value('templateSrv', self.templateSrv);
$provide.value('$element', self.$element);
$provide.value('$sanitize', self.$sanitize);
_.each(mocks, function(value, key) {
$provide.value(key, value);
});
@ -42,8 +45,12 @@ export function ControllerTestContext() {
self.$location = $location;
self.$browser = $browser;
self.$q = $q;
self.panel = new PanelModel({type: 'test'});
self.dashboard = {meta: {}};
self.panel = new PanelModel({ type: 'test' });
self.dashboard = { meta: {} };
self.isUtc = false;
self.dashboard.isTimezoneUtc = function() {
return self.isUtc;
};
$rootScope.appEvent = sinon.spy();
$rootScope.onAppEvent = sinon.spy();
@ -53,14 +60,14 @@ export function ControllerTestContext() {
$rootScope.colors.push('#' + i);
}
config.panels['test'] = {info: {}};
config.panels['test'] = { info: {} };
self.ctrl = $controller(
Ctrl,
{$scope: self.scope},
{ $scope: self.scope },
{
panel: self.panel,
dashboard: self.dashboard,
},
}
);
});
};
@ -72,7 +79,7 @@ export function ControllerTestContext() {
self.$browser = $browser;
self.scope.contextSrv = {};
self.scope.panel = {};
self.scope.dashboard = {meta: {}};
self.scope.dashboard = { meta: {} };
self.scope.dashboardMeta = {};
self.scope.dashboardViewState = new DashboardViewStateStub();
self.scope.appEvent = sinon.spy();
@ -91,6 +98,10 @@ export function ControllerTestContext() {
});
});
};
this.setIsUtc = function(isUtc = false) {
self.isUtc = isUtc;
};
}
export function ServiceTestContext() {
@ -131,7 +142,7 @@ export function DashboardViewStateStub() {
export function TimeSrvStub() {
this.init = sinon.spy();
this.time = {from: 'now-1h', to: 'now'};
this.time = { from: 'now-1h', to: 'now' };
this.timeRange = function(parse) {
if (parse === false) {
return this.time;
@ -159,7 +170,7 @@ export function ContextSrvStub() {
export function TemplateSrvStub() {
this.variables = [];
this.templateSettings = {interpolate: /\[\[([\s\S]+?)\]\]/g};
this.templateSettings = { interpolate: /\[\[([\s\S]+?)\]\]/g };
this.data = {};
this.replace = function(text) {
return _.template(text, this.templateSettings)(this.data);
@ -188,7 +199,7 @@ var allDeps = {
TimeSrvStub: TimeSrvStub,
ControllerTestContext: ControllerTestContext,
ServiceTestContext: ServiceTestContext,
DashboardViewStateStub: DashboardViewStateStub
DashboardViewStateStub: DashboardViewStateStub,
};
// for legacy

View File

@ -12,7 +12,8 @@ module.exports = {
output: {
path: path.resolve(__dirname, '../../public/build'),
filename: '[name].[hash].js',
publicPath: "/public/build/",
// Keep publicPath relative for host.com/grafana/ deployments
publicPath: "public/build/",
},
resolve: {
extensions: ['.ts', '.tsx', '.es6', '.js', '.json'],

View File

@ -85,7 +85,7 @@ module.exports = merge(common, {
]
},
require('./sass.rule.js')({
sourceMap: true, minimize: false, preserveUrl: true
sourceMap: true, minimize: false, preserveUrl: HOT
}, extractSass),
{
test: /\.(ttf|eot|svg|woff(2)?)(\?[a-z0-9=&.]+)?$/,

View File

@ -57,7 +57,7 @@
var rootScope = body.injector().get('$rootScope');
if (!rootScope) {return false;}
var panels = angular.element('div.panel:visible').length;
var panels = angular.element('plugin-component').length;
return rootScope.panelsRendered >= panels;
});