Project

General

Profile

« Previous | Next » 

Revision 42117380

Added by Tomer Brisker about 9 years ago

Fixes #8106 - Dashboard rewrite to allow better customization

View differences:

app/assets/javascripts/dashboard.js
$(document).on('ContentLoad', function(){start_gridster()});
$(document).on("click",".gridster>ul>li>.close" ,function(){ hide_widget(this);});
$(document).on("click",".widget_control .minimize" ,function(){ hide_widget(this);});
$(document).on("click",".widget_control .remove" ,function(){ remove_widget(this);});
function start_gridster(){
if (!$(".gridster>ul>li>.close").exists()) {
$(".gridster>ul>li").prepend("<a class='close'>&times;</a>");
}
read_position();
var gridster = $(".gridster>ul").gridster({
widget_margins: [10, 10],
widget_base_dimensions: [82, 340],
......
}
function save_position(){
var positions = JSON.stringify(serialize_grid());
localStorage.setItem("grid-data", positions);
window.location.reload();
function remove_widget(item){
var widget = $(item).parents('li.gs_w');
var gridster = $(".gridster>ul").gridster().data('gridster');
if (confirm(__("Are you sure you want to delete this widget from your dashboard?"))){
$.ajax({
type: 'delete',
url: $(item).data('url'),
success: function(){
$.jnotify(__("Widget removed from dashboard."), 'success', false);
gridster.remove_widget(widget);
},
error: function(){
$.jnotify(__("Error removing widget from dashboard."), 'error', true);
},
});
}
}
function save_position(path){
var positions = serialize_grid();
$.ajax({type: 'POST',
url: path,
data: {'widgets': positions},
success: function(){
$.jnotify(__("Widget positions successfully saved."), 'success', false);
},
error: function(){
$.jnotify(__("Failed to save widget positions."), 'error', true);
},
dataType: 'json'});
}
function serialize_grid(){
var result = [];
var result = {};
$(".gridster>ul>li").each(function(i, widget) {
result.push({
name: $(widget).attr('data-name'),
id: $(widget).attr('data-id'),
hide: $(widget).attr('data-hide'),
col: $(widget).attr('data-col'),
row: $(widget).attr('data-row'),
size_x: $(widget).attr('data-sizex'),
size_y: $(widget).attr('data-sizey')
});
$widget = $(widget);
result[$widget.data('id')] = {
hide: $widget.data('hide'),
col: $widget.data('col'),
row: $widget.data('row'),
sizex: $widget.data('sizex'),
sizey: $widget.data('sizey')
};
});
return result;
}
function read_position(){
if (localStorage.getItem("grid-data") !== null) {
var positions = JSON.parse(localStorage.getItem("grid-data"));
for (var i = 0; i < positions.length; i++) {
var position = positions[i];
var widget = $(".gridster>ul>li[data-id="+position.id+"]");
widget.attr("data-hide", position.hide);
if(position.hide == 'true') {
widget.attr("data-row", '');
widget.attr("data-col", '');
} else {
widget.attr("data-row", position.row);
widget.attr("data-col", position.col);
}
}
}
}
function reset_position(){
localStorage.removeItem("grid-data");
window.location.reload();
}
function fill_restore_list(){
$("ul>li.widget-restore").remove();
var restore_list = [];
......
gridster.register_widget(widget);
fill_restore_list();
}
}
app/assets/stylesheets/dashboard.scss
*/
#dashboard{
#status-table li{
margin: 10px 0;
list-style: none;
h4{
float:right;
line-height: 10px;
.status-table{
li{
margin: 10px 0;
list-style: none;
h4{
float:right;
line-height: 10px;
}
}
.total{
border-top: 1px solid #ddd;
text-align: right;
padding-top: 10px;
}
}
.header{
border-bottom: 1px solid #ddd;
padding: 10px 20px;
}
.total{
border-top: 1px solid #ddd;
text-align: right;
padding-top: 10px;
padding: 0px 10px 10px;
margin-top: 0;
}
.stats-well {
min-height: 363px;
min-height: 360px;
}
.statistics-bar{
height: 270px;
width: 90%;
height: 260px;
width: 90%;
margin: 0 10px;
}
.statistics-chart{
height: 240px;
width: 95%;
}
.widget_control a{
float: right;
font-size: 21px;
font-weight: bold;
line-height: 1;
color: #000;
text-shadow: 0 1px 0 #fff;
opacity: 0.2;
cursor: pointer;
margin: 0 3px;
&:hover{
text-decoration: none;
}
}
}
......
margin-right: 10px;
margin-top: 80px;
float: right;
}
}
app/controllers/dashboard_controller.rb
class DashboardController < ApplicationController
include Foreman::Controller::AutoCompleteSearch
before_filter :prefetch_data, :only => :index
before_filter :find_resource, :only => [:destroy]
def index
respond_to do |format|
......
end
end
def create
Dashboard::Manager.add_widget_to_user(User.current, params[:widget])
redirect_to root_path
end
def destroy
if @widget.present? && @widget.user == User.current
User.current.widgets.destroy(@widget)
status = :ok
else
status = :forbidden
logger.warn "#{User.current} attempted to remove widget id #{params[:id]} and failed."
end
respond_to do |format|
format.json {render :json => params[:id], :status => status}
end
end
def reset_default
Dashboard::Manager.reset_user_to_default(User.current)
redirect_to root_path
end
def save_positions
errors = []
params[:widgets].each do |id, values|
widget = User.current.widgets.where("id = #{id}").first
errors << widget.errors unless widget.update_attributes(values)
end
respond_to do |format|
if errors.empty?
format.json { render :json => {}, :status => :ok }
else
format.json { render :json => errors, :status => :bad_request }
end
end
rescue => exception
process_ajax_error exception, 'save positions'
end
private
def prefetch_data
......
@hosts = dashboard.hosts
@report = dashboard.report
end
def resource_name
"widget"
end
end
app/helpers/dashboard_helper.rb
def dashboard_actions
[_("Generated at %s") % Time.zone.now.to_s(:short),
select_action_button(_("Manage dashboard"), {},
link_to_function(_("Save dashboard"), 'save_position()'),
link_to_function(_("Reset to default"), 'reset_position()'),
link_to_function(_("Save dashboard"), "save_position('#{save_positions_widgets_path}')"),
link_to(_("Reset to default"), reset_default_widgets_path, :method => :put),
content_tag(:li,'',:class=>'divider'),
content_tag(:li,_("Restore widgets"), :class=>'nav-header', :id=>'restore_list' )
)]
end
def widget_list
Dashboard::Manager.widgets
def render_widget(widget)
render(:partial => widget.template, :locals => widget.data)
end
def widget_data(widget)
{:data=>{:id => widget.id, :name => widget.name, :row => widget.row, :col => widget.col, :sizex=>widget.sizex, :sizey => widget.sizey, :hide=>widget.hide}}
end
def count_reports(hosts)
app/helpers/layout_helper.rb
f.number_field attr, options
end
end
def last_days(days)
content_tag(:h6, n_("last %s day", "last %s days", days) % days, :class => 'ca')
end
end
app/models/user.rb
has_many :filters, :through => :cached_roles
has_many :permissions, :through => :filters
has_many :cached_usergroup_members
has_many :widgets, :dependent => :destroy
has_many :user_mail_notifications, :dependent => :destroy
has_many :mail_notifications, :through => :user_mail_notifications
app/models/widget.rb
class Widget < ActiveRecord::Base
belongs_to :user
validates :user_id, :name, :template, :presence => true
validates :sizex, :sizey, :col, :row, :numericality => {:only_integer => true}
serialize :data
before_validation :default_values
def default_values
self.sizex ||= 4
self.sizey ||= 1
self.col ||= 1
self.row ||= 1
self.hide ||= false
self.data ||= {}
end
end
app/services/dashboard/loader.rb
# We require these files explicitly as the menu classes can't be reloaded
# to keep the singletons working.
require 'menu/node'
require 'menu/item'
require 'menu/divider'
require 'menu/toggle'
require 'menu/manager'
module Dashboard
class Loader
module Loader
# Default widgets that are displayed on the dashboard
DEFAULT_WIDGETS = [ {:template=>'status_widget', :sizex=>8,:sizey=>1,:name=> N_('Status table')},
{:template=>'status_chart_widget', :sizex=>4,:sizey=>1,:name=> N_('Status chart')},
{:template=>'reports_widget', :sizex=>6,:sizey=>1,:name=> N_('Report summary')},
{:template=>'distribution_widget', :sizex=>6,:sizey=>1,:name=> N_('Distribution chart')}]
# Widget templates that are allowed on dashboard. Default widgets automatically allow their templates.
ALLOWED_TEMPLATES = []
def self.load
Manager.map do |dashboard|
dashboard.widget 'status_widget', :row=>1,:col=>1,:sizex=>8,:sizey=>1,:name=> N_('Status table')
dashboard.widget 'status_chart_widget', :row=>1,:col=>9,:sizex=>4,:sizey=>1,:name=> N_('Status chart')
dashboard.widget 'reports_widget', :row=>2,:col=>1,:sizex=>6,:sizey=>1,:name=> N_('Report summary')
dashboard.widget 'distribution_widget', :row=>2,:col=>7,:sizex=>6,:sizey=>1,:name=> N_('Distribution chart')
end
DEFAULT_WIDGETS.each{ |widget| Dashboard::Manager.register_default_widget(widget) }
Dashboard::Manager.register_allowed_templates(ALLOWED_TEMPLATES)
end
end
end
app/services/dashboard/manager.rb
module Dashboard
module Manager
class << self
def map
@widgets ||= []
mapper = Mapper.new(@widgets)
if block_given?
yield mapper
else
mapper
end
end
@default_widgets = []
@allowed_templates = Set.new()
def widgets
# force menu reload in development when auto loading modified files
@widgets ||= Dashboard::Loader.load
end
def self.default_widgets
@default_widgets
end
class Mapper
attr_reader :widgets
def initialize(widgets)
@widgets = widgets
end
# Adds an widget at the end of the list. Available options:
# * before, after: specify where the widget should be inserted (eg. :after => :activity)
def push(obj, options = {})
# menu widget position
if options[:first]
@widgets.unshift(obj)
elsif (before = options[:before]) && exists?(before)
@widgets.insert( position_of(before), obj)
elsif (after = options[:after]) && exists?(after)
@widgets.insert( position_of(after) + 1, obj)
else
@widgets.push(obj)
end
end
def widget(id, options = {})
push(Widget.new(id, options), options)
end
# Removes a menu widget
def delete(name)
if found = self.find(name)
@widgets.remove!(found)
end
end
def self.register_default_widget(widget)
@default_widgets << widget
@allowed_templates << widget[:template]
end
# Checks if a menu widget exists
def exists?(name)
@widgets.any? {|widget| widget.name == name}
end
def self.register_allowed_templates(templates)
@allowed_templates.merge(templates)
end
def find(name)
@widgets.find {|widget| widget.name == name}
end
def self.add_widget_to_user(user, widget)
raise ::Foreman::Exception.new(N_("Unallowed template for dashboard widget: %s"), widget[:template]) unless @allowed_templates.include?(widget[:template])
user.widgets.create!(widget)
end
def position_of(name)
@widgets.each do |widget|
if widget.name == name
return widget.position
end
end
end
def self.reset_user_to_default(user)
user.widgets.clear
@default_widgets.each {|widget|
add_widget_to_user(user, widget)
}
end
end
end
app/services/dashboard/widget.rb
module Dashboard
class Widget
attr_reader :id, :name, :col, :row, :sizex, :sizey, :hide
def initialize(id, options)
@id = id
@name = options[:name] || id
@col = options[:col] || 1
@row = options[:row] || 1
@sizex = options[:sizex] || 4
@sizey = options[:sizey] || 1
@hide = options[:hide] || false
end
end
end
app/services/foreman/access_permissions.rb
end
permission_set.security_block :dashboard do |map|
map.permission :access_dashboard, {:dashboard => [:index],
map.permission :access_dashboard, {:dashboard => [:index, :save_positions, :reset_default, :create, :destroy],
:"api/v1/dashboard" => [:index],
:"api/v2/dashboard" => [:index]
}
app/services/foreman/plugin.rb
ComputeResource.register_provider provider
end
def widget(id, options)
Dashboard::Manager.map.widget(id, options)
def widget(template, options)
Dashboard::Manager.register_default_widget({:template=>template}.merge!(options))
end
# To add FiltersHelper#search_path override,
app/views/dashboard/_distribution_widget.html.erb
<h4 style="text-align: center;"><%= n_("Run distribution in the last %s minute", "Run distribution in the last %s minutes", Setting[:puppet_interval]) % Setting[:puppet_interval] %></h4>
<h4 class="header">
<%= n_("Run distribution in the last %s minute", "Run distribution in the last %s minutes", Setting[:puppet_interval]) % Setting[:puppet_interval] %>
</h4>
<%= render_run_distribution(@hosts, :class => 'statistics-bar') %>
app/views/dashboard/_reports_widget.html.erb
<h4 style="text-align: center;"><%= _("Latest Events") %></h4>
<h4 class="header">
<%= _("Latest Events") %>
</h4>
<% events = latest_events %>
<% if events.empty? %>
<p class="ca"><%= _("No interesting reports received in the last week") %></p>
app/views/dashboard/_status_chart_widget.html.erb
<div id='status-chart'>
<h4 class="header ca"><%= _('Host Configuration Chart') %></h4>
<%= render_overview(@report, :class => 'statistics-pie small') %>
</div>
<h4 class="header">
<%= _('Host Configuration Chart') %>
</h4>
<%= render_overview(@report, :class => 'statistics-pie small') %>
app/views/dashboard/_status_widget.html.erb
<div id='status-table'>
<h4 class="header"><%= _('Host Configuration Status') %></h4>
<ul>
<%= searchable_links _('Hosts that had performed modifications without error'),
"last_report > \"#{Setting[:puppet_interval] + 5} minutes ago\" and (status.applied > 0 or status.restarted > 0) and (status.failed = 0)",
:active_hosts_ok_enabled
%>
<h4 class="header">
<%= _('Host Configuration Status') %>
</h4>
<ul>
<%= searchable_links _('Hosts that had performed modifications without error'),
"last_report > \"#{Setting[:puppet_interval] + 5} minutes ago\" and (status.applied > 0 or status.restarted > 0) and (status.failed = 0)",
:active_hosts_ok_enabled
%>
<%= searchable_links _('Hosts in error state'),
"last_report > \"#{Setting[:puppet_interval] + 5} minutes ago\" and (status.failed > 0 or status.failed_restarts > 0) and status.enabled = true",
:bad_hosts_enabled
%>
<%= searchable_links _('Hosts in error state'),
"last_report > \"#{Setting[:puppet_interval] + 5} minutes ago\" and (status.failed > 0 or status.failed_restarts > 0) and status.enabled = true",
:bad_hosts_enabled
%>
<%=searchable_links _("Good host reports in the last %s") % time_ago_in_words((Setting[:puppet_interval]+5).minutes.ago),
"last_report > \"#{Setting[:puppet_interval]+5} minutes ago\" and status.enabled = true and status.applied = 0 and status.failed = 0 and status.pending = 0",
:ok_hosts_enabled
%>
<%=searchable_links _("Good host reports in the last %s") % time_ago_in_words((Setting[:puppet_interval]+5).minutes.ago),
"last_report > \"#{Setting[:puppet_interval]+5} minutes ago\" and status.enabled = true and status.applied = 0 and status.failed = 0 and status.pending = 0",
:ok_hosts_enabled
%>
<%= searchable_links _('Hosts that had pending changes'),
'status.pending > 0 and status.enabled = true',
:pending_hosts_enabled
%>
<%= searchable_links _('Hosts that had pending changes'),
'status.pending > 0 and status.enabled = true',
:pending_hosts_enabled
%>
<%= searchable_links _('Out of sync hosts'),
"last_report < \"#{Setting[:puppet_interval] + 5} minutes ago\" and status.enabled = true",
:out_of_sync_hosts_enabled
%>
<%= searchable_links _('Out of sync hosts'),
"last_report < \"#{Setting[:puppet_interval] + 5} minutes ago\" and status.enabled = true",
:out_of_sync_hosts_enabled
%>
<%= searchable_links _('Hosts with no reports'),
"not has last_report and status.enabled = true",
:reports_missing
%>
<%= searchable_links _('Hosts with no reports'),
"not has last_report and status.enabled = true",
:reports_missing
%>
<%= searchable_links _('Hosts with alerts disabled'),
"status.enabled = false",
:disabled_hosts
%>
<%= searchable_links _('Hosts with alerts disabled'),
"status.enabled = false",
:disabled_hosts
%>
<h4 class="total"><%= _("Total Hosts: %s") % @report[:total_hosts] %></h4>
</ul>
</div>
<h4 class="total"><%= _("Total Hosts: %s") % @report[:total_hosts] %></h4>
</ul>
app/views/dashboard/index.html.erb
<%= javascript 'dashboard' %>
<% title _('Overview') %>
<%= title_actions dashboard_actions %>
<div class="row">
<div id='dashboard' class="gridster col-md-12">
<ul>
<%= widget_list.map do |widget|
content_tag(:li, render(widget.id), :data=>{:id => widget.id, :name => widget.name || widget.id, :row => widget.row, :col => widget.col, :sizex=>widget.sizex, :sizey => widget.sizey, :hide=>widget.hide})
end.join(' ').html_safe
%>
</ul>
</div>
<% if User.current.widgets.empty? %>
<div class="col-md-12 ca">
<%= link_to(_("Get default dashboard widgets"), reset_default_widgets_path, :method => :put, :class=>'btn btn-default') %>
</div>
<% else %>
<div id='dashboard' class="gridster col-md-12">
<ul>
<% User.current.widgets.each do |widget| %>
<%= content_tag(:li, widget_data(widget)) do %>
<div class='widget_control'>
<a class='remove' data-url='<%= widget_path(widget) %>'>&times;</a>
<a class='minimize'>&minus;</a>
</div>
<div class="widget <%= widget.name.parameterize %>">
<%= render_widget(widget) %>
</div>
<% end %>
<% end %>
</ul>
</div>
<% end %>
</div>
config/routes.rb
end
resources :widgets, :controller => 'dashboard', :only => [:create, :destroy] do
collection do
post 'save_positions', :to => 'dashboard#save_positions'
put 'reset_default', :to => 'dashboard#reset_default'
end
end
root :to => 'dashboard#index'
match 'dashboard', :to => 'dashboard#index', :as => "dashboard"
match 'dashboard/auto_complete_search', :to => 'hosts#auto_complete_search', :as => "auto_complete_search_dashboards"
db/migrate/20140928140206_create_widgets.rb
class CreateWidgets < ActiveRecord::Migration
def change
create_table :widgets do |t|
t.references :user, :index => true
t.string :template, :null => false
t.string :name, :null => false
t.text :data
t.integer :sizex, :default => 4
t.integer :sizey, :default => 1
t.integer :col, :default => 1
t.integer :row, :default => 1
t.boolean :hide, :default => false
t.timestamps
end
end
end
db/migrate/20150225124617_add_default_widgets.rb
class AddDefaultWidgets < ActiveRecord::Migration
def up
User.all.each do |user|
Dashboard::Manager.reset_user_to_default(user)
end
end
def down
end
end
test/integration/dashboard_test.rb
class DashboardTest < ActionDispatch::IntegrationTest
def setup
FactoryGirl.create(:host)
Dashboard::Manager.reset_user_to_default(users(:admin))
end
def assert_dashboard_link(text)
test/unit/widget_test.rb
require 'test_helper'
class WidgetTest < ActiveSupport::TestCase
setup do
@user = FactoryGirl.create(:user)
end
test 'new user should have no widgets' do
assert_blank(@user.widgets)
end
test 'reset to default should add default widgets to user' do
assert_difference('@user.widgets.count', Dashboard::Manager.default_widgets.count) do
Dashboard::Manager.reset_user_to_default(@user)
end
end
test 'adding widget to user should fill in default values for missing fields' do
widget_hash = { :template => Dashboard::Manager.default_widgets[0][:template],
:name => Dashboard::Manager.default_widgets[0][:name] }
assert Dashboard::Manager.add_widget_to_user(@user, widget_hash)
assert_equal @user.widgets.count, 1
widget = @user.widgets.first
assert_equal widget.sizex, 4
assert_equal widget.sizey, 1
assert_equal widget.col, 1
assert_equal widget.row, 1
refute widget.hide
assert_blank widget.data
assert_equal widget.user_id, @user.id
end
test 'adding widget with unallowed template raises exception' do
widget_hash = { :template => 'malicious_template',
:name => 'malicious template name'}
assert_raises ::Foreman::Exception do
Dashboard::Manager.add_widget_to_user(@user, widget_hash)
end
end
end

Also available in: Unified diff