mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Merge branch 'master' into WPH95-feature/add_es_alerting
This commit is contained in:
commit
cde347bd3d
18
.dockerignore
Normal file
18
.dockerignore
Normal 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
2
.gitignore
vendored
@ -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
|
||||
|
15
CHANGELOG.md
15
CHANGELOG.md
@ -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)
|
||||
|
||||
|
26
ROADMAP.md
26
ROADMAP.md
@ -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)
|
||||
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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\.|test2)</code> | Formats multi-value variable into a regex string
|
||||
`pipe` | ${servers:pipe} | `'test.', 'test2'` | <code>test.|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.
|
||||
|
@ -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:
|
||||
|
@ -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]
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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)
|
||||
|
@ -5,7 +5,7 @@ type = "docs"
|
||||
[menu.docs]
|
||||
name = "Developing App Plugins"
|
||||
parent = "developing"
|
||||
weight = 6
|
||||
weight = 4
|
||||
+++
|
||||
|
||||
# Grafana Apps
|
||||
|
@ -5,7 +5,7 @@ type = "docs"
|
||||
[menu.docs]
|
||||
name = "Developing Datasource Plugins"
|
||||
parent = "developing"
|
||||
weight = 6
|
||||
weight = 5
|
||||
+++
|
||||
|
||||
# Datasources
|
||||
|
@ -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)
|
||||
|
@ -5,7 +5,7 @@ type = "docs"
|
||||
[menu.docs]
|
||||
name = "plugin.json Schema"
|
||||
parent = "developing"
|
||||
weight = 6
|
||||
weight = 8
|
||||
+++
|
||||
|
||||
# Plugin.json
|
||||
|
@ -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(/)?(.*)`
|
||||
|
||||
|
@ -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")
|
||||
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
@ -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() {
|
||||
|
@ -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
|
||||
|
@ -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{
|
||||
|
@ -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
|
||||
}
|
||||
}
|
||||
|
@ -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())
|
||||
}
|
||||
}
|
||||
}
|
@ -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
71
pkg/metrics/service.go
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
@ -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
|
||||
}
|
||||
|
@ -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,
|
||||
|
@ -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 {
|
||||
|
@ -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))
|
||||
|
173
pkg/services/alerting/notifiers/discord.go
Normal file
173
pkg/services/alerting/notifiers/discord.go
Normal 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
|
||||
}
|
52
pkg/services/alerting/notifiers/discord_test.go
Normal file
52
pkg/services/alerting/notifiers/discord_test.go
Normal 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/")
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
@ -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))
|
||||
}
|
||||
|
@ -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 {
|
||||
|
@ -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 {
|
||||
|
@ -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()
|
||||
}
|
||||
|
@ -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);"))
|
||||
}
|
||||
|
@ -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)
|
||||
|
@ -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)
|
||||
}
|
||||
|
@ -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 });
|
||||
}
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -19,6 +19,7 @@ export class Analytics {
|
||||
});
|
||||
ga.l = +new Date();
|
||||
ga('create', (<any>config).googleAnalyticsId, 'auto');
|
||||
ga('set', 'anonymizeIp', true);
|
||||
return ga;
|
||||
}
|
||||
|
||||
|
@ -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) {
|
||||
|
@ -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: [
|
||||
['"&ä'],
|
||||
['<strong>"some html"</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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -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() {
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
@ -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);
|
||||
}
|
||||
|
@ -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 /////
|
||||
|
@ -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 };
|
||||
|
@ -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>',
|
||||
|
@ -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'" >
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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);
|
||||
|
||||
|
@ -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);
|
||||
|
||||
|
@ -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() {
|
||||
|
@ -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() {
|
||||
|
@ -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;
|
||||
});
|
||||
}
|
||||
|
@ -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);
|
||||
}
|
||||
|
@ -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() };
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@ -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', () => {
|
||||
|
@ -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);
|
||||
|
@ -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.
|
||||
|
@ -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', () => {
|
||||
|
@ -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();
|
||||
}
|
||||
|
@ -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);
|
||||
|
@ -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;
|
||||
|
@ -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
|
||||
) {
|
||||
|
@ -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();
|
||||
|
@ -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' : '';
|
||||
|
||||
|
@ -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]);
|
||||
|
@ -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'),
|
||||
|
@ -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 |
@ -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;
|
||||
}
|
||||
|
@ -102,5 +102,6 @@ $select-option-selected-bg: $dropdownLinkBackgroundActive;
|
||||
.gf-form-input--form-dropdown-right {
|
||||
.Select-menu-outer {
|
||||
right: 0;
|
||||
left: unset;
|
||||
}
|
||||
}
|
||||
|
@ -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 {
|
||||
|
@ -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 {
|
||||
|
@ -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
|
||||
|
@ -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'],
|
||||
|
@ -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=&.]+)?$/,
|
||||
|
@ -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;
|
||||
});
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user