Преглед изворни кода

Merge branch 'master' of github.com:grafana/grafana

Torkel Ödegaard пре 7 година
родитељ
комит
660dc09fa9

+ 2 - 1
docker/blocks/nginx_proxy/Dockerfile

@@ -1,3 +1,4 @@
 FROM nginx:alpine
 
-COPY nginx.conf /etc/nginx/nginx.conf
+COPY nginx.conf /etc/nginx/nginx.conf
+COPY htpasswd /etc/nginx/htpasswd

+ 3 - 0
docker/blocks/nginx_proxy/htpasswd

@@ -0,0 +1,3 @@
+user1:$apr1$1odeeQb.$kwV8D/VAAGUDU7pnHuKoV0
+user2:$apr1$A2kf25r.$6S0kp3C7vIuixS5CL0XA9.
+admin:$apr1$IWn4DoRR$E2ol7fS/dkI18eU4bXnBO1

+ 20 - 1
docker/blocks/nginx_proxy/nginx.conf

@@ -13,7 +13,26 @@ http {
     listen 10080;
 
     location /grafana/ {
+      ################################################################
+      # Enable these settings to test with basic auth and an auth proxy header
+      # the htpasswd file contains an admin user with password admin and
+      # user1: grafana and user2: grafana
+      ################################################################
+
+      # auth_basic "Restricted Content";
+      # auth_basic_user_file /etc/nginx/htpasswd;
+
+      ################################################################
+      # To use the auth proxy header, set the following in custom.ini:
+      # [auth.proxy]
+      # enabled = true
+      # header_name = X-WEBAUTH-USER
+      # header_property = username
+      ################################################################
+
+      # proxy_set_header X-WEBAUTH-USER $remote_user;
+
       proxy_pass http://localhost:3000/;
     }
   }
-}
+}

+ 286 - 0
docs/sources/http_api/playlist.md

@@ -0,0 +1,286 @@
++++
+title = "Playlist HTTP API "
+description = "Playlist Admin HTTP API"
+keywords = ["grafana", "http", "documentation", "api", "playlist"]
+aliases = ["/http_api/playlist/"]
+type = "docs"
+[menu.docs]
+name = "Playlist"
+parent = "http_api"
++++
+
+# Playlist API
+
+## Search Playlist
+
+`GET /api/playlists`
+
+Get all existing playlist for the current organization using pagination
+
+**Example Request**:
+
+```bash
+GET /api/playlists HTTP/1.1
+Accept: application/json
+Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk
+```
+
+  Querystring Parameters:
+
+  These parameters are used as querystring parameters.
+  
+  - **query** - Limit response to playlist having a name like this value.
+  - **limit** - Limit response to *X* number of playlist.
+
+**Example Response**:
+
+```json
+HTTP/1.1 200
+Content-Type: application/json
+[
+  {
+    "id": 1,
+    "name": "my playlist",
+    "interval": "5m"
+  }
+]
+```
+
+## Get one playlist
+
+`GET /api/playlists/:id`
+
+**Example Request**:
+
+```bash
+GET /api/playlists/1 HTTP/1.1
+Accept: application/json
+Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk
+```
+
+**Example Response**:
+
+```json
+HTTP/1.1 200
+Content-Type: application/json
+{
+  "id" : 1,
+  "name": "my playlist",
+  "interval": "5m",
+  "orgId": "my org",
+  "items": [
+    {
+      "id": 1,
+      "playlistId": 1,
+      "type": "dashboard_by_id",
+      "value": "3",
+      "order": 1,
+      "title":"my third dasboard"
+    },
+    {
+      "id": 2,
+      "playlistId": 1,
+      "type": "dashboard_by_tag",
+      "value": "myTag",
+      "order": 2,
+      "title":"my other dasboard"
+    }
+  ]
+}
+```
+
+## Get Playlist items
+
+`GET /api/playlists/:id/items`
+
+**Example Request**:
+
+```bash
+GET /api/playlists/1/items HTTP/1.1
+Accept: application/json
+Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk
+```
+
+**Example Response**:
+
+```json
+HTTP/1.1 200
+Content-Type: application/json
+[
+  {
+    "id": 1,
+    "playlistId": 1,
+    "type": "dashboard_by_id",
+    "value": "3",
+    "order": 1,
+    "title":"my third dasboard"
+  },
+  {
+    "id": 2,
+    "playlistId": 1,
+    "type": "dashboard_by_tag",
+    "value": "myTag",
+    "order": 2,
+    "title":"my other dasboard"
+  }
+]
+```
+
+## Get Playlist dashboards
+
+`GET /api/playlists/:id/dashboards`
+
+**Example Request**:
+
+```bash
+GET /api/playlists/1/dashboards HTTP/1.1
+Accept: application/json
+Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk
+```
+
+**Example Response**:
+
+```json
+HTTP/1.1 200
+Content-Type: application/json
+[
+  {
+    "id": 3,
+    "title": "my third dasboard",
+    "order": 1,
+  },
+  {
+    "id": 5,
+    "title":"my other dasboard"
+    "order": 2,
+    
+  }
+]
+```
+
+## Create a playlist
+
+`POST /api/playlists/`
+
+**Example Request**:
+
+```bash
+PUT /api/playlists/1 HTTP/1.1
+Accept: application/json
+Content-Type: application/json
+Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk
+  {
+    "name": "my playlist",
+    "interval": "5m",
+    "items": [
+      {
+        "type": "dashboard_by_id",
+        "value": "3",
+        "order": 1,
+        "title":"my third dasboard"
+      },
+      {
+        "type": "dashboard_by_tag",
+        "value": "myTag",
+        "order": 2,
+        "title":"my other dasboard"
+      }
+    ]
+  }
+```
+
+**Example Response**:
+
+```json
+HTTP/1.1 200
+Content-Type: application/json
+  {
+    "id": 1,
+    "name": "my playlist",
+    "interval": "5m"
+  }
+```
+
+## Update a playlist
+
+`PUT /api/playlists/:id`
+
+**Example Request**:
+
+```bash
+PUT /api/playlists/1 HTTP/1.1
+Accept: application/json
+Content-Type: application/json
+Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk
+  {
+    "name": "my playlist",
+    "interval": "5m",
+    "items": [
+      {
+        "playlistId": 1,
+        "type": "dashboard_by_id",
+        "value": "3",
+        "order": 1,
+        "title":"my third dasboard"
+      },
+      {
+        "playlistId": 1,
+        "type": "dashboard_by_tag",
+        "value": "myTag",
+        "order": 2,
+        "title":"my other dasboard"
+      }
+    ]
+  }
+```
+
+**Example Response**:
+
+```json
+HTTP/1.1 200
+Content-Type: application/json
+{
+  "id" : 1,
+  "name": "my playlist",
+  "interval": "5m",
+  "orgId": "my org",
+  "items": [
+    {
+      "id": 1,
+      "playlistId": 1,
+      "type": "dashboard_by_id",
+      "value": "3",
+      "order": 1,
+      "title":"my third dasboard"
+    },
+    {
+      "id": 2,
+      "playlistId": 1,
+      "type": "dashboard_by_tag",
+      "value": "myTag",
+      "order": 2,
+      "title":"my other dasboard"
+    }
+  ]
+}
+```
+
+## Delete a playlist
+
+`DELETE /api/playlists/:id`
+
+**Example Request**:
+
+```bash
+DELETE /api/playlists/1 HTTP/1.1
+Accept: application/json
+Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk
+```
+
+**Example Response**:
+
+```json
+HTTP/1.1 200
+Content-Type: application/json
+{}
+```

+ 1 - 1
docs/sources/installation/configuration.md

@@ -863,7 +863,7 @@ Secret key. e.g. AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
 Url to where Grafana will send PUT request with images
 
 ### public_url
-Optional parameter. Url to send to users in notifications, directly appended with the resulting uploaded file name.
+Optional parameter. Url to send to users in notifications. If the string contains the sequence ${file}, it will be replaced with the uploaded filename. Otherwise, the file name will be appended to the path part of the url, leaving any query string unchanged.
 
 ### username
 basic auth username

+ 1 - 2
pkg/api/api.go

@@ -73,8 +73,7 @@ func (hs *HTTPServer) registerRoutes() {
 	r.Get("/dashboards/", reqSignedIn, Index)
 	r.Get("/dashboards/*", reqSignedIn, Index)
 
-	r.Get("/explore/", reqEditorRole, Index)
-	r.Get("/explore/*", reqEditorRole, Index)
+	r.Get("/explore", reqEditorRole, Index)
 
 	r.Get("/playlists/", reqSignedIn, Index)
 	r.Get("/playlists/*", reqSignedIn, Index)

+ 1 - 0
pkg/api/playlist.go

@@ -160,6 +160,7 @@ func CreatePlaylist(c *m.ReqContext, cmd m.CreatePlaylistCommand) Response {
 
 func UpdatePlaylist(c *m.ReqContext, cmd m.UpdatePlaylistCommand) Response {
 	cmd.OrgId = c.OrgId
+	cmd.Id = c.ParamsInt64(":id")
 
 	if err := bus.Dispatch(&cmd); err != nil {
 		return Error(500, "Failed to save playlist", err)

+ 12 - 3
pkg/components/imguploader/webdavuploader.go

@@ -9,6 +9,7 @@ import (
 	"net/http"
 	"net/url"
 	"path"
+	"strings"
 	"time"
 
 	"github.com/grafana/grafana/pkg/util"
@@ -35,6 +36,16 @@ var netClient = &http.Client{
 	Transport: netTransport,
 }
 
+func (u *WebdavUploader) PublicURL(filename string) string {
+	if strings.Contains(u.public_url, "${file}") {
+		return strings.Replace(u.public_url, "${file}", filename, -1)
+	} else {
+		publicURL, _ := url.Parse(u.public_url)
+		publicURL.Path = path.Join(publicURL.Path, filename)
+		return publicURL.String()
+	}
+}
+
 func (u *WebdavUploader) Upload(ctx context.Context, pa string) (string, error) {
 	url, _ := url.Parse(u.url)
 	filename := util.GetRandomString(20) + ".png"
@@ -65,9 +76,7 @@ func (u *WebdavUploader) Upload(ctx context.Context, pa string) (string, error)
 	}
 
 	if u.public_url != "" {
-		publicURL, _ := url.Parse(u.public_url)
-		publicURL.Path = path.Join(publicURL.Path, filename)
-		return publicURL.String(), nil
+		return u.PublicURL(filename), nil
 	}
 
 	return url.String(), nil

+ 13 - 0
pkg/components/imguploader/webdavuploader_test.go

@@ -2,6 +2,7 @@ package imguploader
 
 import (
 	"context"
+	"net/url"
 	"testing"
 
 	. "github.com/smartystreets/goconvey/convey"
@@ -26,3 +27,15 @@ func TestUploadToWebdav(t *testing.T) {
 		So(path, ShouldStartWith, "http://publicurl:8888/webdav/")
 	})
 }
+
+func TestPublicURL(t *testing.T) {
+	Convey("Given a public URL with parameters, and no template", t, func() {
+		webdavUploader, _ := NewWebdavImageUploader("http://localhost:8888/webdav/", "test", "test", "http://cloudycloud.me/s/DOIFDOMV/download?files=")
+		parsed, _ := url.Parse(webdavUploader.PublicURL("fileyfile.png"))
+		So(parsed.Path, ShouldEndWith, "fileyfile.png")
+	})
+	Convey("Given a public URL with parameters, and a template", t, func() {
+		webdavUploader, _ := NewWebdavImageUploader("http://localhost:8888/webdav/", "test", "test", "http://cloudycloud.me/s/DOIFDOMV/download?files=${file}")
+		So(webdavUploader.PublicURL("fileyfile.png"), ShouldEndWith, "fileyfile.png")
+	})
+}

+ 1 - 1
pkg/models/playlist.go

@@ -63,7 +63,7 @@ type PlaylistDashboards []*PlaylistDashboard
 
 type UpdatePlaylistCommand struct {
 	OrgId    int64             `json:"-"`
-	Id       int64             `json:"id" binding:"Required"`
+	Id       int64             `json:"id"`
 	Name     string            `json:"name" binding:"Required"`
 	Interval string            `json:"interval"`
 	Items    []PlaylistItemDTO `json:"items"`

+ 1 - 0
pkg/services/sqlstore/alert.go

@@ -73,6 +73,7 @@ func HandleAlertsQuery(query *m.GetAlertsQuery) error {
 		alert.name,
 		alert.state,
 		alert.new_state_date,
+		alert.eval_data,
 		alert.eval_date,
 		alert.execution_error,
 		dashboard.uid as dashboard_uid,

+ 13 - 2
pkg/services/sqlstore/alert_test.go

@@ -13,7 +13,7 @@ func mockTimeNow() {
 	var timeSeed int64
 	timeNow = func() time.Time {
 		fakeNow := time.Unix(timeSeed, 0)
-		timeSeed += 1
+		timeSeed++
 		return fakeNow
 	}
 }
@@ -30,7 +30,7 @@ func TestAlertingDataAccess(t *testing.T) {
 		InitTestDB(t)
 
 		testDash := insertTestDashboard("dashboard with alerts", 1, 0, false, "alert")
-
+		evalData, _ := simplejson.NewJson([]byte(`{"test": "test"}`))
 		items := []*m.Alert{
 			{
 				PanelId:     1,
@@ -40,6 +40,7 @@ func TestAlertingDataAccess(t *testing.T) {
 				Message:     "Alerting message",
 				Settings:    simplejson.New(),
 				Frequency:   1,
+				EvalData:    evalData,
 			},
 		}
 
@@ -104,8 +105,18 @@ func TestAlertingDataAccess(t *testing.T) {
 
 			alert := alertQuery.Result[0]
 			So(err2, ShouldBeNil)
+			So(alert.Id, ShouldBeGreaterThan, 0)
+			So(alert.DashboardId, ShouldEqual, testDash.Id)
+			So(alert.PanelId, ShouldEqual, 1)
 			So(alert.Name, ShouldEqual, "Alerting title")
 			So(alert.State, ShouldEqual, "pending")
+			So(alert.NewStateDate, ShouldNotBeNil)
+			So(alert.EvalData, ShouldNotBeNil)
+			So(alert.EvalData.Get("test").MustString(), ShouldEqual, "test")
+			So(alert.EvalDate, ShouldNotBeNil)
+			So(alert.ExecutionError, ShouldEqual, "")
+			So(alert.DashboardUid, ShouldNotBeNil)
+			So(alert.DashboardSlug, ShouldEqual, "dashboard-with-alerts")
 		})
 
 		Convey("Viewer cannot read alerts", func() {

+ 47 - 28
public/app/containers/Explore/Explore.tsx

@@ -2,16 +2,18 @@ import React from 'react';
 import { hot } from 'react-hot-loader';
 import Select from 'react-select';
 
+import kbn from 'app/core/utils/kbn';
 import colors from 'app/core/utils/colors';
 import TimeSeries from 'app/core/time_series2';
 import { decodePathComponent } from 'app/core/utils/location_util';
+import { parse as parseDate } from 'app/core/utils/datemath';
 
 import ElapsedTime from './ElapsedTime';
 import QueryRows from './QueryRows';
 import Graph from './Graph';
 import Table from './Table';
 import TimePicker, { DEFAULT_RANGE } from './TimePicker';
-import { buildQueryOptions, ensureQueries, generateQueryKey, hasQuery } from './utils/query';
+import { ensureQueries, generateQueryKey, hasQuery } from './utils/query';
 
 function makeTimeSeriesList(dataList, options) {
   return dataList.map((seriesData, index) => {
@@ -31,18 +33,20 @@ function makeTimeSeriesList(dataList, options) {
   });
 }
 
-function parseInitialState(initial) {
-  try {
-    const parsed = JSON.parse(decodePathComponent(initial));
-    return {
-      datasource: parsed.datasource,
-      queries: parsed.queries.map(q => q.query),
-      range: parsed.range,
-    };
-  } catch (e) {
-    console.error(e);
-    return { queries: [], range: DEFAULT_RANGE };
+function parseInitialState(initial: string | undefined) {
+  if (initial) {
+    try {
+      const parsed = JSON.parse(decodePathComponent(initial));
+      return {
+        datasource: parsed.datasource,
+        queries: parsed.queries.map(q => q.query),
+        range: parsed.range,
+      };
+    } catch (e) {
+      console.error(e);
+    }
   }
+  return { datasource: null, queries: [], range: DEFAULT_RANGE };
 }
 
 interface IExploreState {
@@ -63,11 +67,12 @@ interface IExploreState {
   tableResult: any;
 }
 
-// @observer
 export class Explore extends React.Component<any, IExploreState> {
+  el: any;
+
   constructor(props) {
     super(props);
-    const { datasource, queries, range } = parseInitialState(props.routeParams.initial);
+    const { datasource, queries, range } = parseInitialState(props.routeParams.state);
     this.state = {
       datasource: null,
       datasourceError: null,
@@ -132,6 +137,10 @@ export class Explore extends React.Component<any, IExploreState> {
     }
   }
 
+  getRef = el => {
+    this.el = el;
+  };
+
   handleAddQueryRow = index => {
     const { queries } = this.state;
     const nextQueries = [
@@ -214,20 +223,33 @@ export class Explore extends React.Component<any, IExploreState> {
     }
   };
 
-  async runGraphQuery() {
+  buildQueryOptions(targetOptions: { format: string; instant: boolean }) {
     const { datasource, queries, range } = this.state;
+    const resolution = this.el.offsetWidth;
+    const absoluteRange = {
+      from: parseDate(range.from, false),
+      to: parseDate(range.to, true),
+    };
+    const { interval } = kbn.calculateInterval(absoluteRange, resolution, datasource.interval);
+    const targets = queries.map(q => ({
+      ...targetOptions,
+      expr: q.query,
+    }));
+    return {
+      interval,
+      range,
+      targets,
+    };
+  }
+
+  async runGraphQuery() {
+    const { datasource, queries } = this.state;
     if (!hasQuery(queries)) {
       return;
     }
     this.setState({ latency: 0, loading: true, graphResult: null, queryError: null });
     const now = Date.now();
-    const options = buildQueryOptions({
-      format: 'time_series',
-      interval: datasource.interval,
-      instant: false,
-      range,
-      queries: queries.map(q => q.query),
-    });
+    const options = this.buildQueryOptions({ format: 'time_series', instant: false });
     try {
       const res = await datasource.query(options);
       const result = makeTimeSeriesList(res.data, options);
@@ -241,18 +263,15 @@ export class Explore extends React.Component<any, IExploreState> {
   }
 
   async runTableQuery() {
-    const { datasource, queries, range } = this.state;
+    const { datasource, queries } = this.state;
     if (!hasQuery(queries)) {
       return;
     }
     this.setState({ latency: 0, loading: true, queryError: null, tableResult: null });
     const now = Date.now();
-    const options = buildQueryOptions({
+    const options = this.buildQueryOptions({
       format: 'table',
-      interval: datasource.interval,
       instant: true,
-      range,
-      queries: queries.map(q => q.query),
     });
     try {
       const res = await datasource.query(options);
@@ -301,7 +320,7 @@ export class Explore extends React.Component<any, IExploreState> {
     const selectedDatasource = datasource ? datasource.name : undefined;
 
     return (
-      <div className={exploreClass}>
+      <div className={exploreClass} ref={this.getRef}>
         <div className="navbar">
           {position === 'left' ? (
             <div>

+ 0 - 12
public/app/containers/Explore/utils/query.ts

@@ -1,15 +1,3 @@
-export function buildQueryOptions({ format, interval, instant, range, queries }) {
-  return {
-    interval,
-    range,
-    targets: queries.map(expr => ({
-      expr,
-      format,
-      instant,
-    })),
-  };
-}
-
 export function generateQueryKey(index = 0) {
   return `Q-${Date.now()}-${Math.random()}-${index}`;
 }

+ 1 - 1
public/app/core/services/keybindingSrv.ts

@@ -191,7 +191,7 @@ export class KeybindingSrv {
               range,
             };
             const exploreState = encodePathComponent(JSON.stringify(state));
-            this.$location.url(`/explore/${exploreState}`);
+            this.$location.url(`/explore?state=${exploreState}`);
           }
         }
       });

+ 1 - 1
public/app/features/panel/metrics_panel_ctrl.ts

@@ -332,7 +332,7 @@ class MetricsPanelCtrl extends PanelCtrl {
       range,
     };
     const exploreState = encodePathComponent(JSON.stringify(state));
-    this.$location.url(`/explore/${exploreState}`);
+    this.$location.url(`/explore?state=${exploreState}`);
   }
 
   addQuery(target) {

+ 1 - 1
public/app/routes/routes.ts

@@ -112,7 +112,7 @@ export function setupAngularRoutes($routeProvider, $locationProvider) {
       controller: 'FolderDashboardsCtrl',
       controllerAs: 'ctrl',
     })
-    .when('/explore/:initial?', {
+    .when('/explore', {
       template: '<react-container />',
       resolve: {
         roles: () => ['Editor', 'Admin'],

+ 7 - 3
public/sass/base/_type.scss

@@ -24,7 +24,7 @@ small {
   font-size: 85%;
 }
 strong {
-  font-weight: bold;
+  font-weight: $font-weight-semi-bold;
 }
 em {
   font-style: italic;
@@ -249,7 +249,7 @@ dd {
   line-height: $line-height-base;
 }
 dt {
-  font-weight: bold;
+  font-weight: $font-weight-semi-bold;
 }
 dd {
   margin-left: $line-height-base / 2;
@@ -376,7 +376,7 @@ a.external-link {
       padding: $spacer*0.5 $spacer;
     }
     th {
-      font-weight: normal;
+      font-weight: $font-weight-semi-bold;
       background: $table-bg-accent;
     }
   }
@@ -415,3 +415,7 @@ a.external-link {
   color: $yellow;
   padding: 0;
 }
+
+th {
+  font-weight: $font-weight-semi-bold;
+}