Przeglądaj źródła

Merge pull request #3882 from bergquist/playlist_tags

Add support for playlist based on tags
Daniel Lee 10 lat temu
rodzic
commit
21632d22ab

+ 2 - 2
docs/sources/reference/playlist.md

@@ -18,11 +18,11 @@ The Playlist feature can be accessed from Grafana's sidemenu. Click the 'Playlis
 
 
 Click on "New Playlist" button to create a new playlist. Firstly, name your playlist and configure a time interval for Grafana to wait on a particular Dashboard before advancing to the next one on the Playlist.
 Click on "New Playlist" button to create a new playlist. Firstly, name your playlist and configure a time interval for Grafana to wait on a particular Dashboard before advancing to the next one on the Playlist.
 
 
-You can search Dashboards by name (or use a regular expression), and add them to your Playlist. By default, your starred dashboards will appear as candidates for the Playlist.
+You can search Dashboards by name (or use a regular expression), and add them to your Playlist. Or you could add tags which will include all the dashboards that belongs to a tag when the playlist start playing. By default, your starred dashboards will appear as candidates for the Playlist.
 
 
 Be sure to click the "Add to dashboard" button next to the Dashboard name to add it to the Playlist. To remove a dashboard from the playlist click on "Remove[x]" button from the playlist.
 Be sure to click the "Add to dashboard" button next to the Dashboard name to add it to the Playlist. To remove a dashboard from the playlist click on "Remove[x]" button from the playlist.
 
 
-Since the Playlist is basically a list of Dashboards, ensure that all the Dashboards you want to appear in your Playlist are added here. 
+Since the Playlist is basically a list of Dashboards, ensure that all the Dashboards you want to appear in your Playlist are added here.
 
 
 ## Saving the playlist
 ## Saving the playlist
 
 

+ 3 - 39
pkg/api/playlist.go

@@ -1,11 +1,8 @@
 package api
 package api
 
 
 import (
 import (
-	"errors"
-	"strconv"
-
 	"github.com/grafana/grafana/pkg/bus"
 	"github.com/grafana/grafana/pkg/bus"
-	"github.com/grafana/grafana/pkg/log"
+	_ "github.com/grafana/grafana/pkg/log"
 	"github.com/grafana/grafana/pkg/middleware"
 	"github.com/grafana/grafana/pkg/middleware"
 	m "github.com/grafana/grafana/pkg/models"
 	m "github.com/grafana/grafana/pkg/models"
 )
 )
@@ -101,39 +98,6 @@ func LoadPlaylistItems(id int64) ([]m.PlaylistItem, error) {
 	return *itemQuery.Result, nil
 	return *itemQuery.Result, nil
 }
 }
 
 
-func LoadPlaylistDashboards(id int64) ([]m.PlaylistDashboardDto, error) {
-	playlistItems, _ := LoadPlaylistItems(id)
-
-	dashboardIds := make([]int64, 0)
-
-	for _, i := range playlistItems {
-		dashboardId, _ := strconv.ParseInt(i.Value, 10, 64)
-		dashboardIds = append(dashboardIds, dashboardId)
-	}
-
-	if len(dashboardIds) == 0 {
-		return make([]m.PlaylistDashboardDto, 0), nil
-	}
-
-	dashboardQuery := m.GetPlaylistDashboardsQuery{DashboardIds: dashboardIds}
-	if err := bus.Dispatch(&dashboardQuery); err != nil {
-		log.Warn("dashboardquery failed: %v", err)
-		return nil, errors.New("Playlist not found")
-	}
-
-	dtos := make([]m.PlaylistDashboardDto, 0)
-	for _, item := range *dashboardQuery.Result {
-		dtos = append(dtos, m.PlaylistDashboardDto{
-			Id:    item.Id,
-			Slug:  item.Slug,
-			Title: item.Title,
-			Uri:   "db/" + item.Slug,
-		})
-	}
-
-	return dtos, nil
-}
-
 func GetPlaylistItems(c *middleware.Context) Response {
 func GetPlaylistItems(c *middleware.Context) Response {
 	id := c.ParamsInt64(":id")
 	id := c.ParamsInt64(":id")
 
 
@@ -147,9 +111,9 @@ func GetPlaylistItems(c *middleware.Context) Response {
 }
 }
 
 
 func GetPlaylistDashboards(c *middleware.Context) Response {
 func GetPlaylistDashboards(c *middleware.Context) Response {
-	id := c.ParamsInt64(":id")
+	playlistId := c.ParamsInt64(":id")
 
 
-	playlists, err := LoadPlaylistDashboards(id)
+	playlists, err := LoadPlaylistDashboards(c.OrgId, c.UserId, playlistId)
 	if err != nil {
 	if err != nil {
 		return ApiError(500, "Could not load dashboards", err)
 		return ApiError(500, "Could not load dashboards", err)
 	}
 	}

+ 88 - 0
pkg/api/playlist_play.go

@@ -0,0 +1,88 @@
+package api
+
+import (
+	"errors"
+	"strconv"
+
+	"github.com/grafana/grafana/pkg/bus"
+	_ "github.com/grafana/grafana/pkg/log"
+	m "github.com/grafana/grafana/pkg/models"
+	"github.com/grafana/grafana/pkg/services/search"
+)
+
+func populateDashboardsById(dashboardByIds []int64) ([]m.PlaylistDashboardDto, error) {
+	result := make([]m.PlaylistDashboardDto, 0)
+
+	if len(dashboardByIds) > 0 {
+		dashboardQuery := m.GetDashboardsQuery{DashboardIds: dashboardByIds}
+		if err := bus.Dispatch(&dashboardQuery); err != nil {
+			return result, errors.New("Playlist not found") //TODO: dont swallow error
+		}
+
+		for _, item := range *dashboardQuery.Result {
+			result = append(result, m.PlaylistDashboardDto{
+				Id:    item.Id,
+				Slug:  item.Slug,
+				Title: item.Title,
+				Uri:   "db/" + item.Slug,
+			})
+		}
+	}
+
+	return result, nil
+}
+
+func populateDashboardsByTag(orgId, userId int64, dashboardByTag []string) []m.PlaylistDashboardDto {
+	result := make([]m.PlaylistDashboardDto, 0)
+
+	if len(dashboardByTag) > 0 {
+		for _, tag := range dashboardByTag {
+			searchQuery := search.Query{
+				Title:     "",
+				Tags:      []string{tag},
+				UserId:    userId,
+				Limit:     100,
+				IsStarred: false,
+				OrgId:     orgId,
+			}
+
+			if err := bus.Dispatch(&searchQuery); err == nil {
+				for _, item := range searchQuery.Result {
+					result = append(result, m.PlaylistDashboardDto{
+						Id:    item.Id,
+						Title: item.Title,
+						Uri:   item.Uri,
+					})
+				}
+			}
+		}
+	}
+
+	return result
+}
+
+func LoadPlaylistDashboards(orgId, userId, playlistId int64) ([]m.PlaylistDashboardDto, error) {
+	playlistItems, _ := LoadPlaylistItems(playlistId)
+
+	dashboardByIds := make([]int64, 0)
+	dashboardByTag := make([]string, 0)
+
+	for _, i := range playlistItems {
+		if i.Type == "dashboard_by_id" {
+			dashboardId, _ := strconv.ParseInt(i.Value, 10, 64)
+			dashboardByIds = append(dashboardByIds, dashboardId)
+		}
+
+		if i.Type == "dashboard_by_tag" {
+			dashboardByTag = append(dashboardByTag, i.Value)
+		}
+	}
+
+	result := make([]m.PlaylistDashboardDto, 0)
+
+	var k, _ = populateDashboardsById(dashboardByIds)
+	result = append(result, k...)
+	result = append(result, populateDashboardsByTag(orgId, userId, dashboardByTag)...)
+
+	return result, nil
+}

+ 5 - 0
pkg/models/dashboards.go

@@ -146,3 +146,8 @@ type GetDashboardTagsQuery struct {
 	OrgId  int64
 	OrgId  int64
 	Result []*DashboardTagCloudItem
 	Result []*DashboardTagCloudItem
 }
 }
+
+type GetDashboardsQuery struct {
+	DashboardIds []int64
+	Result       *[]Dashboard
+}

+ 0 - 9
pkg/models/playlist.go

@@ -76,9 +76,7 @@ type UpdatePlaylistCommand struct {
 	OrgId    int64             `json:"-"`
 	OrgId    int64             `json:"-"`
 	Id       int64             `json:"id" binding:"Required"`
 	Id       int64             `json:"id" binding:"Required"`
 	Name     string            `json:"name" binding:"Required"`
 	Name     string            `json:"name" binding:"Required"`
-	Type     string            `json:"type"`
 	Interval string            `json:"interval"`
 	Interval string            `json:"interval"`
-	Data     []int64           `json:"data"`
 	Items    []PlaylistItemDTO `json:"items"`
 	Items    []PlaylistItemDTO `json:"items"`
 
 
 	Result *PlaylistDTO
 	Result *PlaylistDTO
@@ -86,9 +84,7 @@ type UpdatePlaylistCommand struct {
 
 
 type CreatePlaylistCommand struct {
 type CreatePlaylistCommand struct {
 	Name     string            `json:"name" binding:"Required"`
 	Name     string            `json:"name" binding:"Required"`
-	Type     string            `json:"type"`
 	Interval string            `json:"interval"`
 	Interval string            `json:"interval"`
-	Data     []int64           `json:"data"`
 	Items    []PlaylistItemDTO `json:"items"`
 	Items    []PlaylistItemDTO `json:"items"`
 
 
 	OrgId  int64 `json:"-"`
 	OrgId  int64 `json:"-"`
@@ -121,8 +117,3 @@ type GetPlaylistItemsByIdQuery struct {
 	PlaylistId int64
 	PlaylistId int64
 	Result     *[]PlaylistItem
 	Result     *[]PlaylistItem
 }
 }
-
-type GetPlaylistDashboardsQuery struct {
-	DashboardIds []int64
-	Result       *PlaylistDashboards
-}

+ 18 - 0
pkg/services/sqlstore/dashboard.go

@@ -14,6 +14,7 @@ import (
 func init() {
 func init() {
 	bus.AddHandler("sql", SaveDashboard)
 	bus.AddHandler("sql", SaveDashboard)
 	bus.AddHandler("sql", GetDashboard)
 	bus.AddHandler("sql", GetDashboard)
+	bus.AddHandler("sql", GetDashboards)
 	bus.AddHandler("sql", DeleteDashboard)
 	bus.AddHandler("sql", DeleteDashboard)
 	bus.AddHandler("sql", SearchDashboards)
 	bus.AddHandler("sql", SearchDashboards)
 	bus.AddHandler("sql", GetDashboardTags)
 	bus.AddHandler("sql", GetDashboardTags)
@@ -223,3 +224,20 @@ func DeleteDashboard(cmd *m.DeleteDashboardCommand) error {
 		return nil
 		return nil
 	})
 	})
 }
 }
+
+func GetDashboards(query *m.GetDashboardsQuery) error {
+	if len(query.DashboardIds) == 0 {
+		return m.ErrCommandValidationFailed
+	}
+
+	var dashboards = make([]m.Dashboard, 0)
+
+	err := x.In("id", query.DashboardIds).Find(&dashboards)
+	query.Result = &dashboards
+
+	if err != nil {
+		return err
+	}
+
+	return nil
+}

+ 0 - 18
pkg/services/sqlstore/playlist.go

@@ -15,7 +15,6 @@ func init() {
 	bus.AddHandler("sql", DeletePlaylist)
 	bus.AddHandler("sql", DeletePlaylist)
 	bus.AddHandler("sql", SearchPlaylists)
 	bus.AddHandler("sql", SearchPlaylists)
 	bus.AddHandler("sql", GetPlaylist)
 	bus.AddHandler("sql", GetPlaylist)
-	bus.AddHandler("sql", GetPlaylistDashboards)
 	bus.AddHandler("sql", GetPlaylistItem)
 	bus.AddHandler("sql", GetPlaylistItem)
 }
 }
 
 
@@ -162,20 +161,3 @@ func GetPlaylistItem(query *m.GetPlaylistItemsByIdQuery) error {
 
 
 	return err
 	return err
 }
 }
-
-func GetPlaylistDashboards(query *m.GetPlaylistDashboardsQuery) error {
-	if len(query.DashboardIds) == 0 {
-		return m.ErrCommandValidationFailed
-	}
-
-	var dashboards = make(m.PlaylistDashboards, 0)
-
-	err := x.In("id", query.DashboardIds).Find(&dashboards)
-	query.Result = &dashboards
-
-	if err != nil {
-		return err
-	}
-
-	return nil
-}

+ 44 - 0
pkg/services/sqlstore/playlist_test.go

@@ -0,0 +1,44 @@
+package sqlstore
+
+import (
+	"testing"
+
+	. "github.com/smartystreets/goconvey/convey"
+
+	m "github.com/grafana/grafana/pkg/models"
+)
+
+func TestPlaylistDataAccess(t *testing.T) {
+
+	Convey("Testing Playlist data access", t, func() {
+		InitTestDB(t)
+
+		Convey("Can create playlist", func() {
+			items := []m.PlaylistItemDTO{
+				{Title: "graphite", Value: "graphite", Type: "dashboard_by_tag"},
+				{Title: "Backend response times", Value: "3", Type: "dashboard_by_id"},
+			}
+			cmd := m.CreatePlaylistCommand{Name: "NYC office", Interval: "10m", OrgId: 1, Items: items}
+			err := CreatePlaylist(&cmd)
+			So(err, ShouldBeNil)
+
+			Convey("can update playlist", func() {
+				items := []m.PlaylistItemDTO{
+					{Title: "influxdb", Value: "influxdb", Type: "dashboard_by_tag"},
+					{Title: "Backend response times", Value: "2", Type: "dashboard_by_id"},
+				}
+				query := m.UpdatePlaylistCommand{Name: "NYC office ", OrgId: 1, Id: 1, Interval: "10s", Items: items}
+				err = UpdatePlaylist(&query)
+
+				So(err, ShouldBeNil)
+
+				Convey("can remove playlist", func() {
+					query := m.DeletePlaylistCommand{Id: 1}
+					err = DeletePlaylist(&query)
+
+					So(err, ShouldBeNil)
+				})
+			})
+		})
+	})
+}

+ 1 - 0
public/app/features/playlist/all.js

@@ -1,5 +1,6 @@
 define([
 define([
   './playlists_ctrl',
   './playlists_ctrl',
+  './playlist_search',
   './playlist_srv',
   './playlist_srv',
   './playlist_edit_ctrl',
   './playlist_edit_ctrl',
   './playlist_routes'
   './playlist_routes'

+ 47 - 41
public/app/features/playlist/partials/playlist.html

@@ -1,14 +1,14 @@
 <navbar title="Playlists" title-url="playlists" icon="fa fa-fw fa-list" subnav="true">
 <navbar title="Playlists" title-url="playlists" icon="fa fa-fw fa-list" subnav="true">
 	<ul class="nav">
 	<ul class="nav">
-		<li ng-class="{active: isNew()}" ng-show="isNew()"><a href="datasources/create">New</a></li>
-		<li class="active" ng-show="!isNew()"><a href="playlists/edit/{{playlist.id}}">{{playlist.name}}</a></li>
+		<li ng-class="{active: ctrl.isNew()}" ng-show="ctrl.isNew()"><a href="datasources/create">New</a></li>
+		<li class="active" ng-show="!ctrl.isNew()"><a href="playlists/edit/{{ctrl.playlist.id}}">{{ctrl.playlist.name}}</a></li>
 	</ul>
 	</ul>
 </navbar>
 </navbar>
 
 
 <div class="page-container" ng-form="playlistEditForm">
 <div class="page-container" ng-form="playlistEditForm">
   <div class="page">
   <div class="page">
-    <h2 ng-show="isNew()">New playlist</h2>
-    <h2 ng-show="!isNew()">Edit playlist</h2>
+    <h2 ng-show="ctrl.isNew()">New playlist</h2>
+    <h2 ng-show="!ctrl.isNew()">Edit playlist</h2>
 
 
     <h4>Name and interval</h4>
     <h4>Name and interval</h4>
 
 
@@ -20,7 +20,7 @@
               Name
               Name
             </li>
             </li>
             <li>
             <li>
-              <input type="text" required ng-model="playlist.name" class="input-xlarge tight-form-input">
+              <input type="text" required ng-model="ctrl.playlist.name" class="input-xlarge tight-form-input">
             </li>
             </li>
           </ul>
           </ul>
           <div class="clearfix"></div>
           <div class="clearfix"></div>
@@ -31,7 +31,7 @@
               Interval
               Interval
             </li>
             </li>
             <li>
             <li>
-              <input type="text" required ng-model="playlist.interval" placeholder="5m" class="input-xlarge tight-form-input">
+              <input type="text" required ng-model="ctrl.playlist.interval" placeholder="5m" class="input-xlarge tight-form-input">
             </li>
             </li>
           </ul>
           </ul>
           <div class="clearfix"></div>
           <div class="clearfix"></div>
@@ -39,66 +39,72 @@
       </div>
       </div>
 
 
       <br>
       <br>
-      <h4>Add dashboards</h4>
 
 
-      <div style="display: inline-block">
-        <div class="tight-form last">
-          <ul class="tight-form-list">
-						<li class="tight-form-item">
-							Search
-						</li>
-            <li>
-              <input type="text"
-                     class="tight-form-input input-xlarge last"
-                     ng-model="searchQuery"
-                     placeholder="dashboard search term"
-                     ng-trim="true"
-                     ng-change="search()">
-            </li>
-          </ul>
-          <div class="clearfix"></div>
-        </div>
-      </div>
     </div>
     </div>
   </div>
   </div>
 
 
   <div class="row">
   <div class="row">
     <div class="span5 pull-left">
     <div class="span5 pull-left">
-			<h5>Search results ({{filteredPlaylistItems.length}})</h5>
+      <h5>Add dashboards</h5>
+      <div style="">
+        <playlist-search class="playlist-search-container" search-started="ctrl.searchStarted(promise)"></playlist-search>
+      </div>
+    </div>
+  </div>
+
+  <div class="row">
+    <div class="span5 pull-left" ng-if="ctrl.filteredDashboards.length > 0">
+			<h5>Search results ({{ctrl.filteredDashboards.length}})</h5>
        <table class="grafana-options-table">
        <table class="grafana-options-table">
-        <tr ng-repeat="playlistItem in filteredPlaylistItems">
+        <tr ng-repeat="playlistItem in ctrl.filteredDashboards">
           <td style="white-space: nowrap;">
           <td style="white-space: nowrap;">
             {{playlistItem.title}}
             {{playlistItem.title}}
           </td>
           </td>
           <td style="text-align: center">
           <td style="text-align: center">
-            <button class="btn btn-inverse btn-mini pull-right" ng-click="addPlaylistItem(playlistItem)">
+            <button class="btn btn-inverse btn-mini pull-right" ng-click="ctrl.addPlaylistItem(playlistItem)">
               <i class="fa fa-plus"></i>
               <i class="fa fa-plus"></i>
               Add to playlist
               Add to playlist
             </button>
             </button>
           </td>
           </td>
         </tr>
         </tr>
-        <tr ng-if="isSearchResultsEmpty()">
-          <td colspan="2">
-            <i class="fa fa-warning"></i> Search results empty
-          </td>
-        </tr>
       </table>
       </table>
     </div>
     </div>
+    <div class="playlist-search-results-container" ng-if="ctrl.filteredTags.length > 0">
+      <div class="row">
+        <div class="span6 offset1">
+          <div ng-repeat="tag in ctrl.filteredTags" class="pointer" style="width: 180px; float: left;"
+            ng-class="{'selected': $index === selectedIndex }"
+            ng-click="ctrl.addTagPlaylistItem(tag, $event)">
+            <a class="search-result-tag label label-tag" tag-color-from-name="tag.term">
+              <i class="fa fa-tag"></i>
+              <span>{{tag.term}} &nbsp;({{tag.count}})</span>
+            </a>
+          </div>
+        </div>
+      </div>
+    </div>
     <div class="span5 pull-left">
     <div class="span5 pull-left">
       <h5>Added dashboards</h5>
       <h5>Added dashboards</h5>
       <table class="grafana-options-table">
       <table class="grafana-options-table">
-        <tr ng-repeat="playlistItem in playlistItems">
-          <td style="white-space: nowrap;">
+        <tr ng-repeat="playlistItem in ctrl.playlistItems">
+          <td style="white-space: nowrap;" ng-if="playlistItem.type === 'dashboard_by_id'">
             {{playlistItem.title}}
             {{playlistItem.title}}
           </td>
           </td>
+          <td style="white-space: nowrap;"  ng-if="playlistItem.type === 'dashboard_by_tag'">
+            <a class="search-result-tag label label-tag" tag-color-from-name="playlistItem.title">
+              <i class="fa fa-tag"></i>
+              <span>{{playlistItem.title}}</span>
+            </a>
+          </td>
+
           <td style="text-align: right">
           <td style="text-align: right">
-            <button class="btn btn-inverse btn-mini" ng-hide="$first" ng-click="movePlaylistItemUp(playlistItem)">
+            <button class="btn btn-inverse btn-mini" ng-hide="$first" ng-click="ctrl.movePlaylistItemUp(playlistItem)">
               <i class="fa fa-arrow-up"></i>
               <i class="fa fa-arrow-up"></i>
             </button>
             </button>
-            <button class="btn btn-inverse btn-mini" ng-hide="$last" ng-click="movePlaylistItemDown(playlistItem)">
+            <button class="btn btn-inverse btn-mini" ng-hide="$last" ng-click="ctrl.movePlaylistItemDown(playlistItem)">
               <i class="fa fa-arrow-down"></i>
               <i class="fa fa-arrow-down"></i>
             </button>
             </button>
-            <button class="btn btn-inverse btn-mini" ng-click="removePlaylistItem(playlistItem)">
+            <button class="btn btn-inverse btn-mini" ng-click="ctrl.removePlaylistItem(playlistItem)">
               <i class="fa fa-remove"></i>
               <i class="fa fa-remove"></i>
             </button>
             </button>
           </td>
           </td>
@@ -113,11 +119,11 @@
     <!-- <div class="tight-form"> -->
     <!-- <div class="tight-form"> -->
       <button type="button"
       <button type="button"
               class="btn btn-success"
               class="btn btn-success"
-              ng-disabled="playlistEditForm.$invalid || isPlaylistEmpty()"
-              ng-click="savePlaylist(playlist, playlistItems)">Save</button>
+              ng-disabled="ctrl.playlistEditForm.$invalid || ctrl.isPlaylistEmpty()"
+              ng-click="ctrl.savePlaylist(ctrl.playlist, ctrl.playlistItems)">Save</button>
       <button type="button"
       <button type="button"
               class="btn btn-inverse"
               class="btn btn-inverse"
-              ng-click="backToList()">Cancel</button>
+              ng-click="ctrl.backToList()">Cancel</button>
     <!-- </div> -->
     <!-- </div> -->
   </div>
   </div>
 
 

+ 26 - 0
public/app/features/playlist/partials/playlist_search.html

@@ -0,0 +1,26 @@
+<div class="playlist-search-field-wrapper">
+  <span style="position: relative;">
+    <input  type="text" placeholder="Find dashboards by name" tabindex="1"
+    ng-keydown="ctrl.keyDown($event)" ng-model="ctrl.query.query" ng-model-options="{ debounce: 500 }" spellcheck='false' ng-change="ctrl.searchDashboards()" />
+  </span>
+  <div class="playlist-search-switches">
+    <i class="fa fa-filter"></i>
+    <a class="pointer" href="javascript:void 0;" ng-click="ctrl.showStarred()" tabindex="2">
+      <i class="fa fa-remove" ng-show="ctrl.query.starred"></i>
+      starred
+    </a> |
+    <a class="pointer" href="javascript:void 0;" ng-click="ctrl.getTags()" tabindex="3">
+      <i class="fa fa-remove" ng-show="ctrl.tagsMode"></i>
+      tags
+    </a>
+    <span ng-if="ctrl.query.tag.length">
+      |
+      <span ng-repeat="tagName in ctrl.query.tag">
+        <a ng-click="ctrl.removeTag(tagName, $event)" tag-color-from-name="ctrl.tagName" class="label label-tag">
+          <i class="fa fa-remove"></i>
+          {{tagName}}
+        </a>
+      </span>
+    </span>
+  </div>
+</div>

+ 2 - 2
public/app/features/playlist/partials/playlists.html

@@ -19,7 +19,7 @@
         <th style="width: 25px"></th>
         <th style="width: 25px"></th>
 
 
       </thead>
       </thead>
-      <tr ng-repeat="playlist in playlists">
+      <tr ng-repeat="playlist in ctrl.playlists">
         <td>
         <td>
 					<a href="playlists/edit/{{playlist.id}}">{{playlist.name}}</a>
 					<a href="playlists/edit/{{playlist.id}}">{{playlist.name}}</a>
         </td>
         </td>
@@ -39,7 +39,7 @@
           </a>
           </a>
         </td>
         </td>
         <td  class="text-right">
         <td  class="text-right">
-          <a ng-click="removePlaylist(playlist)" class="btn btn-danger btn-mini">
+          <a ng-click="ctrl.removePlaylist(playlist)" class="btn btn-danger btn-mini">
             <i class="fa fa-remove"></i>
             <i class="fa fa-remove"></i>
           </a>
           </a>
         </td>
         </td>

+ 0 - 144
public/app/features/playlist/playlist_edit_ctrl.js

@@ -1,144 +0,0 @@
-define([
-  'angular',
-  'app/core/config',
-  'lodash'
-],
-function (angular, config, _) {
-  'use strict';
-
-  var module = angular.module('grafana.controllers');
-
-  module.controller('PlaylistEditCtrl', function($scope, playlistSrv, backendSrv, $location, $route) {
-    $scope.filteredPlaylistItems = [];
-    $scope.foundPlaylistItems = [];
-    $scope.searchQuery = '';
-    $scope.loading = false;
-    $scope.playlist = {
-      interval: '10m',
-    };
-    $scope.playlistItems = [];
-
-    $scope.init = function() {
-      if ($route.current.params.id) {
-        var playlistId = $route.current.params.id;
-
-        backendSrv.get('/api/playlists/' + playlistId)
-          .then(function(result) {
-            $scope.playlist = result;
-          });
-
-        backendSrv.get('/api/playlists/' + playlistId + '/items')
-          .then(function(result) {
-            $scope.playlistItems = result;
-          });
-      }
-
-      $scope.search();
-    };
-
-    $scope.search = function() {
-      var query = {limit: 10};
-
-      if ($scope.searchQuery) {
-        query.query = $scope.searchQuery;
-      }
-
-      $scope.loading = true;
-
-      backendSrv.search(query)
-        .then(function(results) {
-          $scope.foundPlaylistItems = results;
-          $scope.filterFoundPlaylistItems();
-        })
-        .finally(function() {
-          $scope.loading = false;
-        });
-    };
-
-    $scope.filterFoundPlaylistItems = function() {
-      $scope.filteredPlaylistItems = _.reject($scope.foundPlaylistItems, function(playlistItem) {
-        return _.findWhere($scope.playlistItems, function(listPlaylistItem) {
-          return parseInt(listPlaylistItem.value) === playlistItem.id;
-        });
-      });
-    };
-
-    $scope.addPlaylistItem = function(playlistItem) {
-      playlistItem.value = playlistItem.id.toString();
-      playlistItem.type = 'dashboard_by_id';
-      playlistItem.order = $scope.playlistItems.length + 1;
-
-      $scope.playlistItems.push(playlistItem);
-      $scope.filterFoundPlaylistItems();
-    };
-
-    $scope.removePlaylistItem = function(playlistItem) {
-      _.remove($scope.playlistItems, function(listedPlaylistItem) {
-        return playlistItem === listedPlaylistItem;
-      });
-      $scope.filterFoundPlaylistItems();
-    };
-
-    $scope.savePlaylist = function(playlist, playlistItems) {
-      var savePromise;
-
-      playlist.items = playlistItems;
-
-      savePromise = playlist.id
-        ? backendSrv.put('/api/playlists/' + playlist.id, playlist)
-        : backendSrv.post('/api/playlists', playlist);
-
-      savePromise
-        .then(function() {
-          $scope.appEvent('alert-success', ['Playlist saved', '']);
-          $location.path('/playlists');
-        }, function() {
-          $scope.appEvent('alert-error', ['Unable to save playlist', '']);
-        });
-    };
-
-    $scope.isNew = function() {
-      return !$scope.playlist.id;
-    };
-
-    $scope.isPlaylistEmpty = function() {
-      return !$scope.playlistItems.length;
-    };
-
-    $scope.isSearchResultsEmpty = function() {
-      return !$scope.foundPlaylistItems.length;
-    };
-
-    $scope.isSearchQueryEmpty = function() {
-      return $scope.searchQuery === '';
-    };
-
-    $scope.backToList = function() {
-      $location.path('/playlists');
-    };
-
-    $scope.isLoading = function() {
-      return $scope.loading;
-    };
-
-    $scope.movePlaylistItem = function(playlistItem, offset) {
-      var currentPosition = $scope.playlistItems.indexOf(playlistItem);
-      var newPosition = currentPosition + offset;
-
-      if (newPosition >= 0 && newPosition < $scope.playlistItems.length) {
-        $scope.playlistItems.splice(currentPosition, 1);
-        $scope.playlistItems.splice(newPosition, 0, playlistItem);
-      }
-    };
-
-    $scope.movePlaylistItemUp = function(playlistItem) {
-      $scope.movePlaylistItem(playlistItem, -1);
-    };
-
-    $scope.movePlaylistItemDown = function(playlistItem) {
-      $scope.movePlaylistItem(playlistItem, 1);
-    };
-
-    $scope.init();
-  });
-});

+ 132 - 0
public/app/features/playlist/playlist_edit_ctrl.ts

@@ -0,0 +1,132 @@
+///<reference path="../../headers/common.d.ts" />
+
+import angular from 'angular';
+import _ from 'lodash';
+import coreModule from '../../core/core_module';
+import config from 'app/core/config';
+
+export class PlaylistEditCtrl {
+  filteredDashboards: any = [];
+  filteredTags: any = [];
+  searchQuery: string = '';
+  loading: boolean = false;
+  playlist: any = {
+    interval: '10m',
+  };
+  playlistItems: any = [];
+  dashboardresult: any = [];
+  tagresult: any = [];
+
+  /** @ngInject */
+  constructor(private $scope, private playlistSrv, private backendSrv, private $location, private $route) {
+    if ($route.current.params.id) {
+      var playlistId = $route.current.params.id;
+
+      backendSrv.get('/api/playlists/' + playlistId)
+        .then((result) => {
+          this.playlist = result;
+        });
+
+      backendSrv.get('/api/playlists/' + playlistId + '/items')
+        .then((result) => {
+          this.playlistItems = result;
+        });
+    }
+  }
+
+  filterFoundPlaylistItems() {
+    this.filteredDashboards = _.reject(this.dashboardresult, (playlistItem) => {
+      return _.findWhere(this.playlistItems, (listPlaylistItem) => {
+        return parseInt(listPlaylistItem.value) === playlistItem.id;
+      });
+    });
+
+    this.filteredTags = this.tagresult;
+  }
+
+  addPlaylistItem(playlistItem) {
+    playlistItem.value = playlistItem.id.toString();
+    playlistItem.type = 'dashboard_by_id';
+    playlistItem.order = this.playlistItems.length + 1;
+
+    this.playlistItems.push(playlistItem);
+    this.filterFoundPlaylistItems();
+  }
+
+  addTagPlaylistItem(tag) {
+    var playlistItem: any = {
+      value: tag.term,
+      type: 'dashboard_by_tag',
+      order: this.playlistItems.length + 1,
+      title: tag.term
+    };
+
+    this.playlistItems.push(playlistItem);
+    this.filterFoundPlaylistItems();
+  }
+
+  removePlaylistItem(playlistItem) {
+    _.remove(this.playlistItems, (listedPlaylistItem) => {
+      return playlistItem === listedPlaylistItem;
+    });
+    this.filterFoundPlaylistItems();
+  };
+
+  savePlaylist(playlist, playlistItems) {
+    var savePromise;
+
+    playlist.items = playlistItems;
+
+    savePromise = playlist.id
+      ? this.backendSrv.put('/api/playlists/' + playlist.id, playlist)
+      : this.backendSrv.post('/api/playlists', playlist);
+
+    savePromise
+      .then(() => {
+        this.$scope.appEvent('alert-success', ['Playlist saved', '']);
+        this.$location.path('/playlists');
+      }, () => {
+        this.$scope.appEvent('alert-error', ['Unable to save playlist', '']);
+      });
+  }
+
+  isNew() {
+    return !this.playlist.id;
+  }
+
+  isPlaylistEmpty() {
+    return !this.playlistItems.length;
+  }
+
+  backToList() {
+    this.$location.path('/playlists');
+  }
+
+  searchStarted(promise) {
+    promise.then((data) => {
+      this.dashboardresult = data.dashboardResult;
+      this.tagresult = data.tagResult;
+      this.filterFoundPlaylistItems();
+    });
+  }
+
+  movePlaylistItem(playlistItem, offset) {
+    var currentPosition = this.playlistItems.indexOf(playlistItem);
+    var newPosition = currentPosition + offset;
+
+    if (newPosition >= 0 && newPosition < this.playlistItems.length) {
+      this.playlistItems.splice(currentPosition, 1);
+      this.playlistItems.splice(newPosition, 0, playlistItem);
+    }
+  }
+
+  movePlaylistItemUp(playlistItem) {
+    this.movePlaylistItem(playlistItem, -1);
+  }
+
+  movePlaylistItemDown(playlistItem) {
+    this.movePlaylistItem(playlistItem, 1);
+  }
+}
+
+coreModule.controller('PlaylistEditCtrl', PlaylistEditCtrl);

+ 4 - 1
public/app/features/playlist/playlist_routes.js

@@ -1,4 +1,4 @@
-define([
+  define([
   'angular',
   'angular',
   'app/core/config',
   'app/core/config',
   'lodash'
   'lodash'
@@ -12,14 +12,17 @@ function (angular) {
     $routeProvider
     $routeProvider
       .when('/playlists', {
       .when('/playlists', {
         templateUrl: 'app/features/playlist/partials/playlists.html',
         templateUrl: 'app/features/playlist/partials/playlists.html',
+        controllerAs: 'ctrl',
         controller : 'PlaylistsCtrl'
         controller : 'PlaylistsCtrl'
       })
       })
       .when('/playlists/create', {
       .when('/playlists/create', {
         templateUrl: 'app/features/playlist/partials/playlist.html',
         templateUrl: 'app/features/playlist/partials/playlist.html',
+        controllerAs: 'ctrl',
         controller : 'PlaylistEditCtrl'
         controller : 'PlaylistEditCtrl'
       })
       })
       .when('/playlists/edit/:id', {
       .when('/playlists/edit/:id', {
         templateUrl: 'app/features/playlist/partials/playlist.html',
         templateUrl: 'app/features/playlist/partials/playlist.html',
+        controllerAs: 'ctrl',
         controller : 'PlaylistEditCtrl'
         controller : 'PlaylistEditCtrl'
       })
       })
       .when('/playlists/play/:id', {
       .when('/playlists/play/:id', {

+ 83 - 0
public/app/features/playlist/playlist_search.ts

@@ -0,0 +1,83 @@
+///<reference path="../../headers/common.d.ts" />
+
+import angular from 'angular';
+import config from 'app/core/config';
+import _ from 'lodash';
+import $ from 'jquery';
+import coreModule from '../../core/core_module';
+
+export class PlaylistSearchCtrl {
+  query: any;
+  tagsMode: boolean;
+
+  searchStarted: any;
+
+  /** @ngInject */
+  constructor(private $scope, private $location, private $timeout, private backendSrv, private contextSrv) {
+    this.query = { query: '', tag: [], starred: false };
+
+    $timeout(() => {
+      this.query.query = '';
+      this.searchDashboards();
+    }, 100);
+  }
+
+  searchDashboards() {
+    this.tagsMode = false;
+    var prom: any = {};
+
+    prom.promise = this.backendSrv.search(this.query).then((result) => {
+      return {
+        dashboardResult: result,
+        tagResult: []
+      };
+    });
+
+    this.searchStarted(prom);
+  }
+
+  showStarred() {
+    this.query.starred = !this.query.starred;
+    this.searchDashboards();
+  }
+
+  queryHasNoFilters() {
+    return this.query.query === '' && this.query.starred === false && this.query.tag.length === 0;
+  }
+
+  filterByTag(tag, evt) {
+    this.query.tag.push(tag);
+    this.searchDashboards();
+    if (evt) {
+      evt.stopPropagation();
+      evt.preventDefault();
+    }
+  }
+
+  getTags() {
+    var prom: any = {};
+    prom.promise = this.backendSrv.get('/api/dashboards/tags').then((result) => {
+      return {
+        dashboardResult: [],
+        tagResult: result
+      };
+    });
+
+    this.searchStarted(prom);
+  }
+}
+
+export function playlistSearchDirective() {
+  return {
+    restrict: 'E',
+    templateUrl: 'app/features/playlist/partials/playlist_search.html',
+    controller: PlaylistSearchCtrl,
+    bindToController: true,
+    controllerAs: 'ctrl',
+    scope: {
+      searchStarted: '&'
+    },
+  };
+}
+
+coreModule.directive('playlistSearch', playlistSearchDirective);

+ 0 - 43
public/app/features/playlist/playlists_ctrl.js

@@ -1,43 +0,0 @@
-define([
-  'angular',
-  'lodash'
-],
-function (angular, _) {
-  'use strict';
-
-  var module = angular.module('grafana.controllers');
-
-  module.controller('PlaylistsCtrl', function($scope, $location, backendSrv) {
-    backendSrv.get('/api/playlists')
-      .then(function(result) {
-        $scope.playlists = result;
-      });
-
-    $scope.removePlaylistConfirmed = function(playlist) {
-      _.remove($scope.playlists, {id: playlist.id});
-
-      backendSrv.delete('/api/playlists/' + playlist.id)
-      .then(function() {
-        $scope.appEvent('alert-success', ['Playlist deleted', '']);
-      }, function() {
-        $scope.appEvent('alert-error', ['Unable to delete playlist', '']);
-        $scope.playlists.push(playlist);
-      });
-    };
-
-    $scope.removePlaylist = function(playlist) {
-
-      $scope.appEvent('confirm-modal', {
-        title: 'Confirm delete playlist',
-        text: 'Are you sure you want to delete playlist ' + playlist.name + '?',
-        yesText: "Delete",
-        icon: "fa-warning",
-        onConfirm: function() {
-          $scope.removePlaylistConfirmed(playlist);
-        }
-      });
-
-    };
-
-  });
-});

+ 44 - 0
public/app/features/playlist/playlists_ctrl.ts

@@ -0,0 +1,44 @@
+///<reference path="../../headers/common.d.ts" />
+
+import angular from 'angular';
+import _ from 'lodash';
+import coreModule from '../../core/core_module';
+
+export class PlaylistsCtrl {
+  playlists: any;
+
+  /** @ngInject */
+  constructor(private $scope, private $location, private backendSrv) {
+    backendSrv.get('/api/playlists')
+      .then((result) => {
+        this.playlists = result;
+      });
+  }
+
+  removePlaylistConfirmed(playlist) {
+    _.remove(this.playlists, { id: playlist.id });
+
+    this.backendSrv.delete('/api/playlists/' + playlist.id)
+      .then(() => {
+        this.$scope.appEvent('alert-success', ['Playlist deleted', '']);
+      }, () => {
+        this.$scope.appEvent('alert-error', ['Unable to delete playlist', '']);
+        this.playlists.push(playlist);
+      });
+  }
+
+  removePlaylist(playlist) {
+
+    this.$scope.appEvent('confirm-modal', {
+      title: 'Confirm delete playlist',
+      text: 'Are you sure you want to delete playlist ' + playlist.name + '?',
+      yesText: "Delete",
+      icon: "fa-warning",
+      onConfirm: () => {
+        this.removePlaylistConfirmed(playlist);
+      }
+    });
+  }
+}
+
+coreModule.controller('PlaylistsCtrl', PlaylistsCtrl);

+ 0 - 86
public/app/features/playlist/specs/playlist-edit-ctrl-specs.ts

@@ -1,86 +0,0 @@
-import '../playlist_edit_ctrl';
-import {describe, beforeEach, it, expect, angularMocks} from 'test/lib/common';
-import helpers from 'test/specs/helpers';
-
-describe('PlaylistEditCtrl', function() {
-    var ctx = new helpers.ControllerTestContext();
-
-    var searchResult = [
-        {
-            id: 2,
-            title: 'dashboard: 2'
-        },
-        {
-            id: 3,
-            title: 'dashboard: 3'
-        }
-    ];
-
-    var playlistSrv = {};
-    var backendSrv = {
-      search: (query) => {
-        return ctx.$q.when(searchResult);
-      }
-    };
-
-    beforeEach(angularMocks.module('grafana.core'));
-    beforeEach(angularMocks.module('grafana.controllers'));
-    beforeEach(angularMocks.module('grafana.services'));
-    beforeEach(ctx.providePhase({
-        playlistSrv: playlistSrv,
-        backendSrv: backendSrv,
-        $route: { current: { params: { } } },
-    }));
-
-    beforeEach(ctx.createControllerPhase('PlaylistEditCtrl'));
-
-    beforeEach(() => {
-        ctx.scope.$digest();
-    });
-
-    describe('searchresult returns 2 dashboards', function() {
-        it('found dashboard should be 2', function() {
-            expect(ctx.scope.foundPlaylistItems.length).to.be(2);
-        });
-
-        it('filtred dashboard should be 2', function() {
-            expect(ctx.scope.filteredPlaylistItems.length).to.be(2);
-        });
-
-        describe('adds one dashboard to playlist', () => {
-            beforeEach(() => {
-                ctx.scope.addPlaylistItem({ id: 2, title: 'dashboard: 2' });
-            });
-
-            it('playlistitems should be increased by one', () => {
-                expect(ctx.scope.playlistItems.length).to.be(1);
-            });
-
-            it('filtred playlistitems should be reduced by one', () => {
-                expect(ctx.scope.filteredPlaylistItems.length).to.be(1);
-            });
-
-            it('found dashboard should be 2', function() {
-                expect(ctx.scope.foundPlaylistItems.length).to.be(2);
-            });
-
-            describe('removes one dashboard from playlist', () => {
-              beforeEach(() => {
-                  ctx.scope.removePlaylistItem(ctx.scope.playlistItems[0]);
-              });
-
-              it('playlistitems should be increased by one', () => {
-                  expect(ctx.scope.playlistItems.length).to.be(0);
-              });
-
-              it('found dashboard should be 2', function() {
-                  expect(ctx.scope.foundPlaylistItems.length).to.be(2);
-              });
-
-              it('filtred playlist should be reduced by one', () => {
-                  expect(ctx.scope.filteredPlaylistItems.length).to.be(2);
-              });
-            });
-        });
-    });
-});

+ 69 - 0
public/app/features/playlist/specs/playlist_edit_ctrl_specs.ts

@@ -0,0 +1,69 @@
+import '../playlist_edit_ctrl';
+import {describe, beforeEach, it, expect} from 'test/lib/common';
+import {PlaylistEditCtrl} from '../playlist_edit_ctrl';
+
+describe.only('PlaylistEditCtrl', function() {
+  var ctx: any;
+  beforeEach(() => {
+    ctx = new PlaylistEditCtrl(null, null, null, null, { current: { params: {} } });
+
+    ctx.dashboardresult = [
+      { id: 2, title: 'dashboard: 2' },
+      { id: 3, title: 'dashboard: 3' }
+    ];
+
+    ctx.tagresult = [
+      { term: 'graphie', count: 1 },
+      { term: 'nyc', count: 2 }
+    ];
+  });
+
+  describe('searchresult returns 2 dashboards', function() {
+    it('found dashboard should be 2', function() {
+      expect(ctx.dashboardresult.length).to.be(2);
+    });
+
+    it('filtred dashboard should be 2', function() {
+      ctx.filterFoundPlaylistItems();
+      expect(ctx.filteredDashboards.length).to.be(2);
+    });
+
+    describe('adds one dashboard to playlist', () => {
+      beforeEach(() => {
+        ctx.addPlaylistItem({ id: 2, title: 'dashboard: 2' });
+        ctx.filterFoundPlaylistItems();
+      });
+
+      it('playlistitems should be increased by one', () => {
+        expect(ctx.playlistItems.length).to.be(1);
+      });
+
+      it('filtred playlistitems should be reduced by one', () => {
+        expect(ctx.filteredDashboards.length).to.be(1);
+      });
+
+      it('found dashboard should be 2', function() {
+        expect(ctx.dashboardresult.length).to.be(2);
+      });
+
+      describe('removes one dashboard from playlist', () => {
+        beforeEach(() => {
+          ctx.removePlaylistItem(ctx.playlistItems[0]);
+          ctx.filterFoundPlaylistItems();
+        });
+
+        it('playlistitems should be increased by one', () => {
+          expect(ctx.playlistItems.length).to.be(0);
+        });
+
+        it('found dashboard should be 2', function() {
+          expect(ctx.dashboardresult.length).to.be(2);
+        });
+
+        it('filtred playlist should be reduced by one', () => {
+          expect(ctx.filteredDashboards.length).to.be(2);
+        });
+      });
+    });
+  });
+});

+ 1 - 0
public/less/grafana.less

@@ -8,6 +8,7 @@
 @import "bootstrap-tagsinput.less";
 @import "bootstrap-tagsinput.less";
 @import "tables_lists.less";
 @import "tables_lists.less";
 @import "search.less";
 @import "search.less";
+@import "playlist.less";
 @import "panel.less";
 @import "panel.less";
 @import "forms.less";
 @import "forms.less";
 @import "tightform.less";
 @import "tightform.less";

+ 93 - 0
public/less/playlist.less

@@ -0,0 +1,93 @@
+.playlist-search-container {
+  margin: 15px;
+  z-index: 1000;
+  position: relative;
+  width: 700px;
+  box-shadow: 0px 0px 55px 0px black;
+  background-color: @grafanaPanelBackground;
+
+  .label-tag {
+    margin-left: 6px;
+    font-size: 11px;
+    padding: 2px 6px;
+  }
+}
+
+.playlist-search-switches {
+  position: relative;
+  top: -39px;
+  left: 260px;
+}
+
+.playlist-search-field-wrapper {
+  input {
+    width: 100%;
+    padding: 8px 8px;
+    height: 100%;
+    box-sizing: border-box;
+  }
+  button {
+    margin: 0 4px 0 0;
+  }
+  > span {
+    display: block;
+    overflow: hidden;
+  }
+}
+
+.playlist-search-results-container {
+  min-height: 100px;
+  overflow: auto;
+  display: block;
+  line-height: 28px;
+
+  .search-item:hover, .search-item.selected {
+    background-color: @grafanaListHighlight;
+  }
+
+  .selected {
+    .search-result-tag {
+      opacity: 0.70;
+      color: white;
+    }
+  }
+
+  .fa-star, .fa-star-o {
+    padding-left: 13px;
+  }
+
+  .fa-star {
+    color: @orange;
+  }
+
+  .search-result-link {
+    color: @grafanaListMainLinkColor;
+    .fa {
+      padding-right: 10px;
+    }
+  }
+
+  .search-item {
+    display: block;
+    padding: 3px 10px;
+    white-space: nowrap;
+    background-color: @grafanaListBackground;
+    margin-bottom: 4px;
+    .search-result-icon:before {
+      content: "\f009";
+    }
+
+    &.search-item-dash-home .search-result-icon:before {
+      content: "\f015";
+    }
+  }
+
+  .search-result-tags {
+    float: right;
+  }
+
+  .search-result-actions {
+    float: right;
+    padding-left: 20px;
+  }
+}