Merge pull request #3655 from grafana/playlist

Persistable playlists

closes #515
closes #1137
This commit is contained in:
Carl Bergquist
2016-01-12 15:20:43 +01:00
22 changed files with 1066 additions and 171 deletions

View File

@@ -10,27 +10,32 @@ The Playlist is a special type of Dashboard that rotates through a list of Dashb
Since Grafana automatically scales Dashboards to any resolution they're perfect for big screens!
## Configuring the Playlist
## Creating a Playlist
The Playlist can be accessed from the main Dashboard picker. Click the 'Playlist' button at the bottom of the picker to access the Playlist functionality.
The Playlist feature can be accessed from Grafana's sidemenu. Click the 'Playlist' button from the sidemenu to access the Playlist functionality. When 'Playlist' button is clicked, playlist view will open up showing saved playlists and an option to create new playlists.
<img src="/img/v2/dashboard_search.png" class="no-shadow">
Since the Playlist is basically a list of Dashboards, ensure that all the Dashboards you want to appear in your Playlist are added here. You can search Dashboards by name (or use a regular expression).
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.
Be sure to click the right arrow appearing next to the Dashboard name to add it to 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.
You can configure a time interval for Grafana to wait on a particular Dashboard before advancing to the next one on 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.
## Starting and controlling the Playlist
## Saving the playlist
To start the Playlist, click the green "Start" button
Once all the wanted dashboards are added to a playlist, you can save this playlist by clicking on the green "Save" button. This will generate a unique URL for you playlist which can be shared if needed. Click on the generated URL or on the "Play" button from the "Saved playlists" list to start the playlist. If you want to share the URL, right click on the URL and copy the URL link and share.
## Starting the playlist
Also, if you want, you can start the playlist without saving it by clicking on the green "Start" button at the bottom.
## Controlling the Playlist
Playlists can also be manually controlled utilizing the Playlist controls at the top of screen when in Playlist mode.
Click the stop button to stop the Playlist, and exit to the current Dashboard.
Click the next button to advance to the next Dashboard in the Playlist.
Click the back button to rewind to the previous Dashboard in the Playlist.

View File

@@ -47,6 +47,9 @@ func Register(r *macaron.Macaron) {
r.Get("/dashboard/*", reqSignedIn, Index)
r.Get("/dashboard-solo/*", reqSignedIn, Index)
r.Get("/playlists/", reqSignedIn, Index)
r.Get("/playlists/*", reqSignedIn, Index)
// sign up
r.Get("/signup", Index)
r.Get("/api/user/signup/options", wrap(GetSignUpOptions))
@@ -169,6 +172,17 @@ func Register(r *macaron.Macaron) {
r.Get("/tags", GetDashboardTags)
})
// Playlist
r.Group("/playlists", func() {
r.Get("/", wrap(SearchPlaylists))
r.Get("/:id", ValidateOrgPlaylist, wrap(GetPlaylist))
r.Get("/:id/items", ValidateOrgPlaylist, wrap(GetPlaylistItems))
r.Get("/:id/dashboards", ValidateOrgPlaylist, wrap(GetPlaylistDashboards))
r.Delete("/:id", reqEditorRole, ValidateOrgPlaylist, wrap(DeletePlaylist))
r.Put("/:id", reqEditorRole, bind(m.UpdatePlaylistQuery{}), ValidateOrgPlaylist, wrap(UpdatePlaylist))
r.Post("/", reqEditorRole, bind(m.CreatePlaylistQuery{}), wrap(CreatePlaylist))
})
// Search
r.Get("/search/", Search)

View File

@@ -53,6 +53,12 @@ func setIndexViewData(c *middleware.Context) (*dtos.IndexViewData, error) {
Href: "/",
})
data.MainNavLinks = append(data.MainNavLinks, &dtos.NavLink{
Text: "Playlists",
Icon: "fa fa-fw fa-list",
Href: "/playlists",
})
if c.OrgRole == m.ROLE_ADMIN {
data.MainNavLinks = append(data.MainNavLinks, &dtos.NavLink{
Text: "Data Sources",

194
pkg/api/playlist.go Normal file
View File

@@ -0,0 +1,194 @@
package api
import (
"errors"
"github.com/grafana/grafana/pkg/bus"
"github.com/grafana/grafana/pkg/log"
"github.com/grafana/grafana/pkg/middleware"
m "github.com/grafana/grafana/pkg/models"
"strconv"
)
func ValidateOrgPlaylist(c *middleware.Context) {
id := c.ParamsInt64(":id")
query := m.GetPlaylistByIdQuery{Id: id}
err := bus.Dispatch(&query)
if err != nil {
c.JsonApiErr(404, "Playlist not found", err)
return
}
if query.Result.OrgId != c.OrgId {
c.JsonApiErr(403, "You are not allowed to edit/view playlist", nil)
return
}
}
func SearchPlaylists(c *middleware.Context) Response {
query := c.Query("query")
limit := c.QueryInt("limit")
if limit == 0 {
limit = 1000
}
searchQuery := m.PlaylistQuery{
Title: query,
Limit: limit,
OrgId: c.OrgId,
}
err := bus.Dispatch(&searchQuery)
if err != nil {
return ApiError(500, "Search failed", err)
}
return Json(200, searchQuery.Result)
}
func GetPlaylist(c *middleware.Context) Response {
id := c.ParamsInt64(":id")
cmd := m.GetPlaylistByIdQuery{Id: id}
if err := bus.Dispatch(&cmd); err != nil {
return ApiError(500, "Playlist not found", err)
}
playlistDTOs, _ := LoadPlaylistItemDTOs(id)
dto := &m.PlaylistDTO{
Id: cmd.Result.Id,
Title: cmd.Result.Title,
Interval: cmd.Result.Interval,
OrgId: cmd.Result.OrgId,
Items: playlistDTOs,
}
return Json(200, dto)
}
func LoadPlaylistItemDTOs(id int64) ([]m.PlaylistItemDTO, error) {
playlistitems, err := LoadPlaylistItems(id)
if err != nil {
return nil, err
}
playlistDTOs := make([]m.PlaylistItemDTO, 0)
for _, item := range playlistitems {
playlistDTOs = append(playlistDTOs, m.PlaylistItemDTO{
Id: item.Id,
PlaylistId: item.PlaylistId,
Type: item.Type,
Value: item.Value,
Order: item.Order,
Title: item.Title,
})
}
return playlistDTOs, nil
}
func LoadPlaylistItems(id int64) ([]m.PlaylistItem, error) {
itemQuery := m.GetPlaylistItemsByIdQuery{PlaylistId: id}
if err := bus.Dispatch(&itemQuery); err != nil {
return nil, err
}
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 {
id := c.ParamsInt64(":id")
playlistDTOs, err := LoadPlaylistItemDTOs(id)
if err != nil {
return ApiError(500, "Could not load playlist items", err)
}
return Json(200, playlistDTOs)
}
func GetPlaylistDashboards(c *middleware.Context) Response {
id := c.ParamsInt64(":id")
playlists, err := LoadPlaylistDashboards(id)
if err != nil {
return ApiError(500, "Could not load dashboards", err)
}
return Json(200, playlists)
}
func DeletePlaylist(c *middleware.Context) Response {
id := c.ParamsInt64(":id")
cmd := m.DeletePlaylistQuery{Id: id}
if err := bus.Dispatch(&cmd); err != nil {
return ApiError(500, "Failed to delete playlist", err)
}
return Json(200, "")
}
func CreatePlaylist(c *middleware.Context, query m.CreatePlaylistQuery) Response {
query.OrgId = c.OrgId
err := bus.Dispatch(&query)
if err != nil {
return ApiError(500, "Failed to create playlist", err)
}
return Json(200, query.Result)
}
func UpdatePlaylist(c *middleware.Context, query m.UpdatePlaylistQuery) Response {
err := bus.Dispatch(&query)
if err != nil {
return ApiError(500, "Failed to save playlist", err)
}
playlistDTOs, err := LoadPlaylistItemDTOs(query.Id)
if err != nil {
return ApiError(500, "Failed to save playlist", err)
}
query.Result.Items = playlistDTOs
return Json(200, query.Result)
}

120
pkg/models/playlist.go Normal file
View File

@@ -0,0 +1,120 @@
package models
import (
"errors"
)
// Typed errors
var (
ErrPlaylistNotFound = errors.New("Playlist not found")
ErrPlaylistWithSameNameExists = errors.New("A playlist with the same name already exists")
)
// Playlist model
type Playlist struct {
Id int64 `json:"id"`
Title string `json:"title"`
Interval string `json:"interval"`
OrgId int64 `json:"-"`
}
type PlaylistDTO struct {
Id int64 `json:"id"`
Title string `json:"title"`
Interval string `json:"interval"`
OrgId int64 `json:"-"`
Items []PlaylistItemDTO `json:"items"`
}
type PlaylistItemDTO struct {
Id int64 `json:"id"`
PlaylistId int64 `json:"playlistid"`
Type string `json:"type"`
Title string `json:"title"`
Value string `json:"value"`
Order int `json:"order"`
}
type PlaylistDashboard struct {
Id int64 `json:"id"`
Slug string `json:"slug"`
Title string `json:"title"`
}
type PlaylistItem struct {
Id int64
PlaylistId int64
Type string
Value string
Order int
Title string
}
func (this PlaylistDashboard) TableName() string {
return "dashboard"
}
type Playlists []*Playlist
type PlaylistDashboards []*PlaylistDashboard
//
// DTOS
//
type PlaylistDashboardDto struct {
Id int64 `json:"id"`
Slug string `json:"slug"`
Title string `json:"title"`
Uri string `json:"uri"`
}
//
// COMMANDS
//
type PlaylistQuery struct {
Title string
Limit int
OrgId int64
Result Playlists
}
type UpdatePlaylistQuery struct {
Id int64
Title string
Type string
Interval string
Items []PlaylistItemDTO
Result *PlaylistDTO
}
type CreatePlaylistQuery struct {
Title string
Type string
Interval string
Data []int64
OrgId int64
Items []PlaylistItemDTO
Result *Playlist
}
type GetPlaylistByIdQuery struct {
Id int64
Result *Playlist
}
type GetPlaylistItemsByIdQuery struct {
PlaylistId int64
Result *[]PlaylistItem
}
type GetPlaylistDashboardsQuery struct {
DashboardIds []int64
Result *PlaylistDashboards
}
type DeletePlaylistQuery struct {
Id int64
}

View File

@@ -20,6 +20,7 @@ func AddMigrations(mg *Migrator) {
addQuotaMigration(mg)
addPluginBundleMigration(mg)
addSessionMigration(mg)
addPlaylistMigrations(mg)
}
func addMigrationLogMigrations(mg *Migrator) {

View File

@@ -0,0 +1,32 @@
package migrations
import . "github.com/grafana/grafana/pkg/services/sqlstore/migrator"
func addPlaylistMigrations(mg *Migrator) {
playlistV1 := Table{
Name: "playlist",
Columns: []*Column{
{Name: "id", Type: DB_BigInt, IsPrimaryKey: true, IsAutoIncrement: true},
{Name: "title", Type: DB_NVarchar, Length: 255, Nullable: false},
{Name: "interval", Type: DB_NVarchar, Length: 255, Nullable: false},
{Name: "org_id", Type: DB_BigInt, Nullable: false},
},
}
// create table
mg.AddMigration("create playlist table v1", NewAddTableMigration(playlistV1))
playlistItemV1 := Table{
Name: "playlist_item",
Columns: []*Column{
{Name: "id", Type: DB_BigInt, IsPrimaryKey: true, IsAutoIncrement: true},
{Name: "playlist_id", Type: DB_BigInt, Nullable: false},
{Name: "type", Type: DB_NVarchar, Length: 255, Nullable: false},
{Name: "value", Type: DB_Text, Nullable: false},
{Name: "title", Type: DB_Text, Nullable: false},
{Name: "order", Type: DB_Int, Nullable: false},
},
}
mg.AddMigration("create playlist item table v1", NewAddTableMigration(playlistItemV1))
}

View File

@@ -0,0 +1,180 @@
package sqlstore
import (
"fmt"
"github.com/go-xorm/xorm"
"github.com/grafana/grafana/pkg/bus"
m "github.com/grafana/grafana/pkg/models"
)
func init() {
bus.AddHandler("sql", CreatePlaylist)
bus.AddHandler("sql", UpdatePlaylist)
bus.AddHandler("sql", DeletePlaylist)
bus.AddHandler("sql", SearchPlaylists)
bus.AddHandler("sql", GetPlaylist)
bus.AddHandler("sql", GetPlaylistDashboards)
bus.AddHandler("sql", GetPlaylistItem)
}
func CreatePlaylist(query *m.CreatePlaylistQuery) error {
var err error
playlist := m.Playlist{
Title: query.Title,
Interval: query.Interval,
OrgId: query.OrgId,
}
_, err = x.Insert(&playlist)
fmt.Printf("%v", playlist.Id)
playlistItems := make([]m.PlaylistItem, 0)
for _, item := range query.Items {
playlistItems = append(playlistItems, m.PlaylistItem{
PlaylistId: playlist.Id,
Type: item.Type,
Value: item.Value,
Order: item.Order,
Title: item.Title,
})
}
_, err = x.Insert(&playlistItems)
query.Result = &playlist
return err
}
func UpdatePlaylist(query *m.UpdatePlaylistQuery) error {
var err error
x.Logger.SetLevel(5)
playlist := m.Playlist{
Id: query.Id,
Title: query.Title,
Interval: query.Interval,
}
existingPlaylist := x.Where("id = ?", query.Id).Find(m.Playlist{})
if existingPlaylist == nil {
return m.ErrPlaylistNotFound
}
query.Result = &m.PlaylistDTO{
Id: playlist.Id,
OrgId: playlist.OrgId,
Title: playlist.Title,
Interval: playlist.Interval,
}
_, err = x.Id(query.Id).Cols("id", "title", "timespan").Update(&playlist)
if err != nil {
return err
}
rawSql := "DELETE FROM playlist_item WHERE playlist_id = ?"
_, err = x.Exec(rawSql, query.Id)
if err != nil {
return err
}
playlistItems := make([]m.PlaylistItem, 0)
for _, item := range query.Items {
playlistItems = append(playlistItems, m.PlaylistItem{
PlaylistId: playlist.Id,
Type: item.Type,
Value: item.Value,
Order: item.Order,
Title: item.Title,
})
}
_, err = x.Insert(&playlistItems)
return err
}
func GetPlaylist(query *m.GetPlaylistByIdQuery) error {
if query.Id == 0 {
return m.ErrCommandValidationFailed
}
playlist := m.Playlist{}
_, err := x.Id(query.Id).Get(&playlist)
query.Result = &playlist
return err
}
func DeletePlaylist(query *m.DeletePlaylistQuery) error {
if query.Id == 0 {
return m.ErrCommandValidationFailed
}
return inTransaction(func(sess *xorm.Session) error {
var rawPlaylistSql = "DELETE FROM playlist WHERE id = ?"
_, err := sess.Exec(rawPlaylistSql, query.Id)
if err != nil {
return err
}
var rawItemSql = "DELETE FROM playlist_item WHERE playlist_id = ?"
_, err2 := sess.Exec(rawItemSql, query.Id)
return err2
})
}
func SearchPlaylists(query *m.PlaylistQuery) error {
var playlists = make(m.Playlists, 0)
sess := x.Limit(query.Limit)
if query.Title != "" {
sess.Where("title LIKE ?", query.Title)
}
sess.Where("org_id = ?", query.OrgId)
err := sess.Find(&playlists)
query.Result = playlists
return err
}
func GetPlaylistItem(query *m.GetPlaylistItemsByIdQuery) error {
if query.PlaylistId == 0 {
return m.ErrCommandValidationFailed
}
var playlistItems = make([]m.PlaylistItem, 0)
err := x.Where("playlist_id=?", query.PlaylistId).Find(&playlistItems)
query.Result = &playlistItems
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
}

View File

@@ -4,6 +4,7 @@ define([
'./annotations/annotationsSrv',
'./templating/templateSrv',
'./dashboard/all',
'./playlist/all',
'./panel/all',
'./profile/profileCtrl',
'./profile/changePasswordCtrl',

View File

@@ -4,7 +4,6 @@ define([
'./dashboardNavCtrl',
'./snapshotTopNavCtrl',
'./saveDashboardAsCtrl',
'./playlistCtrl',
'./rowCtrl',
'./shareModalCtrl',
'./shareSnapshotCtrl',
@@ -12,7 +11,6 @@ define([
'./dashboardSrv',
'./keybindings',
'./viewStateSrv',
'./playlistSrv',
'./timeSrv',
'./unsavedChangesSrv',
'./directives/dashSearchView',

View File

@@ -1,55 +0,0 @@
define([
'angular',
'lodash',
'app/core/config'
],
function (angular, _, config) {
'use strict';
var module = angular.module('grafana.controllers');
module.controller('PlaylistCtrl', function($scope, playlistSrv, backendSrv) {
$scope.init = function() {
$scope.playlist = [];
$scope.timespan = config.playlist_timespan;
$scope.search();
};
$scope.search = function() {
var query = {starred: true, limit: 10};
if ($scope.searchQuery) {
query.query = $scope.searchQuery;
query.starred = false;
}
backendSrv.search(query).then(function(results) {
$scope.searchHits = results;
$scope.filterHits();
});
};
$scope.filterHits = function() {
$scope.filteredHits = _.reject($scope.searchHits, function(dash) {
return _.findWhere($scope.playlist, {uri: dash.uri});
});
};
$scope.addDashboard = function(dashboard) {
$scope.playlist.push(dashboard);
$scope.filterHits();
};
$scope.removeDashboard = function(dashboard) {
$scope.playlist = _.without($scope.playlist, dashboard);
$scope.filterHits();
};
$scope.start = function() {
playlistSrv.start($scope.playlist, $scope.timespan);
};
});
});

View File

@@ -0,0 +1,6 @@
define([
'./playlists_ctrl',
'./playlistSrv',
'./playlist_edit_ctrl',
'./playlist_routes'
], function () {});

View File

@@ -0,0 +1,5 @@
<p class="text-center">Are you sure want to delete "{{playlist.title}}" playlist?</p>
<p class="text-center">
<button type="button" class="btn btn-danger" ng-click="removePlaylist()">Yes</button>
<button type="button" class="btn btn-default" ng-click="dismiss()">No</button>
</p>

View File

@@ -0,0 +1,120 @@
<topnav icon="fa fa-fw fa-list" title="Playlists"></topnav>
<div class="page-container" ng-form="playlistEditForm">
<div class="page">
<h2 ng-show="isNew()">New playlist</h2>
<h2 ng-show="!isNew()">Edit playlist</h2>
<h5>1. Name and interval</h5>
<div style="margin-bottom: 10px;">
<div>
<div class="tight-form">
<ul class="tight-form-list">
<li class="tight-form-item" style="width: 100px">
<strong>Title</strong>
</li>
<li>
<input type="text" required ng-model="playlist.title" class="input-xlarge tight-form-input">
</li>
</ul>
<div class="clearfix"></div>
</div>
<div class="tight-form last">
<ul class="tight-form-list">
<li class="tight-form-item" style="width: 100px">
<strong>Interval</strong>
</li>
<li>
<input type="text" required ng-model="playlist.interval" placeholder="5m" class="input-xlarge tight-form-input">
</li>
</ul>
<div class="clearfix"></div>
</div>
</div>
<br>
<h5>2. Add dashboards</h5>
<div style="display: inline-block">
<div class="tight-form last">
<ul class="tight-form-list">
<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>
<li class="tight-form-item last" style="padding: 5px 4px">
<button ng-click="search()" class="btn btn-mini btn-inverse">Search</button>
</li>
</ul>
<div class="clearfix"></div>
</div>
</div>
</div>
</div>
<div>
<div class="span5 pull-left">
<h5>Search results ({{filteredPlaylistItems.length}})</h5>
<table class="grafana-options-table">
<tr ng-repeat="playlistItem in filteredPlaylistItems">
<td style="white-space: nowrap;">
{{playlistItem.title}}
</td>
<td style="text-align: center">
<button class="btn btn-inverse btn-mini pull-right" ng-click="addPlaylistItem(playlistItem)">
<i class="fa fa-plus"></i>
Add to playlist
</button>
</td>
</tr>
<tr ng-if="isSearchResultsEmpty() || isSearchQueryEmpty()">
<td colspan="2">
<i class="fa fa-warning"></i> Search results empty
</td>
</tr>
</table>
</div>
<div class="span5 pull-left">
<h5>Playlist dashboards</h5>
<table class="grafana-options-table">
<tr ng-repeat="playlistItem in playlistItems">
<td style="white-space: nowrap;">
{{playlistItem.title}}
</td>
<td style="text-align: right">
<button class="btn btn-inverse btn-mini" ng-hide="$first" ng-click="movePlaylistItemUp(playlistItem)">
<i class="fa fa-arrow-up"></i>
</button>
<button class="btn btn-inverse btn-mini" ng-hide="$last" ng-click="movePlaylistItemDown(playlistItem)">
<i class="fa fa-arrow-down"></i>
</button>
<button class="btn btn-inverse btn-mini" ng-click="removePlaylistItem(playlistItem)">
<i class="fa fa-remove"></i>
</button>
</td>
</tr>
</table>
</div>
</div>
<div class="clearfix"></div>
<div class="pull-left" style="margin-top: 25px;">
<!-- <div class="tight-form"> -->
<button type="button"
class="btn btn-success"
ng-disabled="playlistEditForm.$invalid || isPlaylistEmpty()"
ng-click="savePlaylist(playlist, playlistItems)">Save</button>
<button type="button"
class="btn btn-default"
ng-click="backToList()">Cancel</button>
<!-- </div> -->
</div>
<div class="clearfix"></div>
</div>

View File

@@ -0,0 +1,48 @@
<topnav icon="fa fa-fw fa-list" title="Playlists"></topnav>
<div class="page-container" style="background: transparent; border: 0;">
<div class="page-wide">
<h2>Saved playlists</h2>
<button type="submit" class="btn btn-success pull-right" ng-click="createPlaylist()">
<i class="fa fa-plus"></i> New playlist</button>
<br />
<table class="filter-table" style="margin-top: 20px">
<thead>
<th><strong>Title</strong></th>
<th><strong>Url</strong></th>
<th style="width: 61px"></th>
<th style="width: 61px"></th>
<th style="width: 25px"></th>
</thead>
<tr ng-repeat="playlist in playlists">
<td >
{{playlist.title}}
</td>
<td >
<a href="{{ playlistUrl(playlist) }}">{{ playlistUrl(playlist) }}</a>
</td>
<td class="text-center">
<a href="{{ playlistUrl(playlist) }}" class="btn btn-inverse btn-mini">
<i class="fa fa-play"></i>
Play
</a>
</td>
<td class="text-right">
<a href="playlists/edit/{{playlist.id}}" class="btn btn-inverse btn-mini">
<i class="fa fa-edit"></i>
Edit
</a>
</td>
<td class="text-right">
<a ng-click="removePlaylist(playlist)" class="btn btn-danger btn-mini">
<i class="fa fa-remove"></i>
</a>
</td>
</tr>
</table>
</div>
</div>

View File

@@ -28,11 +28,11 @@ function (angular, _, kbn) {
self.next();
};
this.start = function(dashboards, timespan) {
this.start = function(dashboards, interval) {
self.stop();
self.index = 0;
self.interval = kbn.interval_to_ms(timespan);
self.interval = kbn.interval_to_ms(interval);
self.dashboards = dashboards;
$rootScope.playlistSrv = this;

View File

@@ -0,0 +1,144 @@
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 = {};
$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 = {starred: true, limit: 10};
if ($scope.searchQuery) {
query.query = $scope.searchQuery;
query.starred = false;
}
$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.moveDashboard(playlistItem, -1);
};
$scope.movePlaylistItemDown = function(playlistItem) {
$scope.moveDashboard(playlistItem, 1);
};
$scope.init();
});
});

View File

@@ -0,0 +1,43 @@
define([
'angular',
'app/core/config',
'lodash'
],
function (angular) {
'use strict';
var module = angular.module('grafana.routes');
module.config(function($routeProvider) {
$routeProvider
.when('/playlists', {
templateUrl: 'app/features/playlist/partials/playlists.html',
controller : 'PlaylistsCtrl'
})
.when('/playlists/create', {
templateUrl: 'app/features/playlist/partials/playlist.html',
controller : 'PlaylistEditCtrl'
})
.when('/playlists/edit/:id', {
templateUrl: 'app/features/playlist/partials/playlist.html',
controller : 'PlaylistEditCtrl'
})
.when('/playlists/play/:id', {
templateUrl: 'app/partials/dashboard.html',
controller : 'LoadDashboardCtrl',
resolve: {
init: function(backendSrv, playlistSrv, $route) {
var playlistId = $route.current.params.id;
return backendSrv.get('/api/playlists/' + playlistId)
.then(function(playlist) {
return backendSrv.get('/api/playlists/' + playlistId + '/dashboards')
.then(function(dashboards) {
playlistSrv.start(dashboards, playlist.interval);
});
});
}
}
});
});
});

View File

@@ -0,0 +1,51 @@
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.playlistUrl = function(playlist) {
return '/playlists/play/' + playlist.id;
};
$scope.removePlaylist = function(playlist) {
var modalScope = $scope.$new(true);
modalScope.playlist = playlist;
modalScope.removePlaylist = function() {
modalScope.dismiss();
_.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.appEvent('show-modal', {
src: './app/features/playlist/partials/playlist-remove.html',
scope: modalScope
});
};
$scope.createPlaylist = function() {
$location.path('/playlists/create');
};
});
});

View File

@@ -0,0 +1,86 @@
import '../playlist_edit_ctrl';
import {describe, beforeEach, it, sinon, 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);
});
});
});
});
});

View File

@@ -1,100 +0,0 @@
<div ng-controller="PlaylistCtrl" ng-init="init()">
<div class="gf-box-header">
<div class="gf-box-title">
<i class="fa fa-play"></i>
Start dashboard playlist
</div>
<button class="gf-box-header-close-btn" ng-click="dismiss();">
<i class="fa fa-remove"></i>
</button>
</div>
<div class="gf-box-body">
<div class="row-fluid" style="margin-bottom: 10px;">
<div class="span12">
<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="query or empty for starred" ng-change="search()">
</li>
</ul>
<div class="clearfix"></div>
</div>
</div>
</div>
</div>
<div class="row-fluid">
<div class="span6">
<h5>Search result</h5>
<table class="grafana-options-table">
<tr ng-repeat="dashboard in filteredHits">
<td style="white-space: nowrap;">
{{dashboard.title}}
</td>
<td style="text-align: center">
<button class="btn btn-inverse btn-mini pull-right" ng-click="addDashboard(dashboard)">
<i class="fa fa-plus"></i>
Add to playlist
</button>
</td>
</tr>
<tr ng-hide="searchHits.length">
<td colspan="2">
<i class="fa fa-warning"></i> No dashboards found
</td>
</tr>
</table>
</div>
<div class="span6">
<h5>Playlist dashboards</h5>
<table class="grafana-options-table">
<tr ng-repeat="dashboard in playlist">
<td style="white-space: nowrap;">
{{dashboard.title}}
</td>
<td style="text-align: center">
<button class="btn btn-inverse btn-mini pull-right" ng-click="removeDashboard(dashboard)">
<i class="fa fa-remove"></i>
</button>
</td>
</tr>
<tr ng-hide="playlist.length">
<td colspan="2">
Playlist empty
</td>
</tr>
</table>
</div>
</div>
<br>
<br>
<div class="pull-left">
<div class="tight-form last">
<ul class="tight-form-list">
<li class="tight-form-item">
Timespan between dashboard change
</li>
<li>
<input type="text" class="tight-form-input input-small" ng-model="timespan" />
</li>
<li>
<button class="btn btn-success tight-form-btn" ng-click="start();dismiss();"><i class="fa fa-play"></i> Start</button>
</li>
</ul>
<div class="clearfix"></div>
</div>
</div>
<div class="clearfix"></div>
</div>
</div>

View File

@@ -71,10 +71,6 @@
<i class="fa fa-download"></i>
Import
</a>
<button class="btn btn-inverse pull-left" dash-editor-link="app/partials/playlist.html" editor-scope="isolated" ng-click="dismiss();">
<i class="fa fa-play"></i>
Playlist
</button>
<div class="clearfix"></div>
</div>