diff --git a/app/controllers/admin/pools_controller.rb b/app/controllers/admin/pools_controller.rb new file mode 100644 index 0000000..dadb7c0 --- /dev/null +++ b/app/controllers/admin/pools_controller.rb @@ -0,0 +1,58 @@ +class Admin::PoolsController < Admin::BaseController + before_action :fetch_pool, only: [:show, :edit, :update] + + # GET /admin/pools + def index + @pools = Pool.all + end + + # GET /admin/pools/new + def new + @pool = Pool.new + end + + # GET /admin/pools/:id/edit + def edit; end + + # GET /admin/pools/:id + def show; end + + # POST /admin/pools + def create + @pool = Pool.new(fetch_params) + + if @pool.submit_to_bacula + flash[:success] = 'Pool created succesfully' + redirect_to admin_pools_path + else + flash[:alert] = 'Pool not created' + render :new + end + end + + # PATCH /admin/pools/:id + def update + if @pool.update_attributes(fetch_params) + flash[:success] = 'Pool updated succesfully' + redirect_to admin_pools_path + else + flash[:alert] = 'Pool not updated' + render :edit + end + end + + private + + def fetch_pool + @pool = Pool.find(params[:id]) + end + + def fetch_params + params.require(:pool).permit( + [ + :name, :name_confirmation, :vol_retention, :use_once, :auto_prune, :recycle, + :max_vols, :max_vol_jobs, :max_vol_files, :max_vol_bytes, :label_format + ] + ) + end +end diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 86124f0..1fdf26a 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -1,109 +1,119 @@ module ApplicationHelper # Custom helper for better display of big numbers # @example number_by_magnitude(4242) # "4.2K" # # @param number[Numeric] # @return [String] human friendly respresentation def number_by_magnitude(number) number_to_human(number, units: { thousand: :K, million: :M, billion: :G }) end # Creates a bootstrap form-group div with an additional 'Add' button next to the select field # # @param object[ActiveRecord::Object] the form's subject # @param resource[Symbol] the objects class # @param attr[Symbol] the select box's attribute # @param attr_name[String] the attribute's display name # @param options[Array] the select box options # @param path[String] the add button's path def select_with_errors_and_button(object, resource, attr, attr_name, options, path) has_errors = object.errors[attr].present? content_tag(:div, class: "form-group #{' has-error' if has_errors }") do attr_label = label(resource, attr, attr_name, class: 'control-label col-xs-5 required') select_div = content_tag(:div, class: 'col-xs-5') do select_part = select_tag([resource, attr].join('_').to_sym, options, include_blank: true, name: "#{resource}[#{attr}]", class: 'form-control' ) if has_errors select_part.concat(content_tag(:span, class: 'help-block') { object.errors[attr].first }) end select_part end button_part = content_tag(:div, class: 'col-xs-1') do link_to path do content_tag(:span, class: 'glyphicon glyphicon-plus text-success') {} end end attr_label.concat(select_div).concat(button_part) end end # Returns a style class depending on the given parameter # # @param status[Char] def success_class(status) case status when 'T' then 'success' when 'E' then 'danger' when 'f' then 'fatal' end end # Fetches the html class for a given path # # @param path[String] the path to check for # @param partial[Boolean] forces a left partial match # # @return [Hash] { class: 'active' } if the given path is the current page def active_class(path, partial = false) if current_page?(path) || (partial && request.path.starts_with?(path)) { class: 'active' } else {} end end # Constructs a breadcrumb out the given options # # @param options[Hash] a hash containing the breadcrumb links in name: path sets # @return an html ol breadcrumb def breadcrumb_with(options) content_tag(:ol, class: 'breadcrumb') do options.map { |name, path| content_tag(:li, active_class(path)) do link_to_if !current_page?(path), name, path end }.inject { |result, element| result.concat(element) } end end # Constructs a list with the given array elements # # @example: # inline_list([:foo, :bar]) # # # # @param arr[Array] # @return an html ul list def inline_list(arr) content_tag(:ul, class: 'list-inline') do arr.map { |element| content_tag(:li) do content_tag(:span, class: 'label label-default') do element end end }.inject { |result, element| result.concat(element) } end end + + # Generates a span with a yes or no and the corresponding formatting + # according to the value's falseness + # + # @param value[Integer] + def yes_no(value) + klass = value == 1 ? 'label label-success' : 'label label-danger' + text = value == 1 ? 'yes' : 'no' + content_tag(:span, class: klass) { text } + end end diff --git a/app/models/pool.rb b/app/models/pool.rb index 8061ea9..8767743 100644 --- a/app/models/pool.rb +++ b/app/models/pool.rb @@ -1,45 +1,116 @@ # Bacula Pool # # The Pool table contains one entry for each media pool controlled by Bacula in # this database. One media record exists for each of the NumVols contained in the Pool. # The PoolType is a Bacula defined keyword. # The MediaType is defined by the administrator, and corresponds to the MediaType # specified in the Director's Storage definition record. # The CurrentVol is the sequence number of the Media record for the current volume. class Pool < ActiveRecord::Base self.table_name = :Pool self.primary_key = :PoolId alias_attribute :pool_id, :PoolId alias_attribute :name, :Name alias_attribute :num_vols, :NumVols alias_attribute :max_vols, :MaxVols alias_attribute :use_once, :UseOnce alias_attribute :use_catalog, :UseCatalog alias_attribute :accept_any_volume, :AcceptAnyVolume alias_attribute :vol_retention, :VolRetention alias_attribute :vol_use_duration, :VolUseDuration alias_attribute :max_vol_jobs, :MaxVolJobs alias_attribute :max_vol_files, :MaxVolFiles alias_attribute :max_vol_bytes, :MaxVolBytes alias_attribute :auto_prune, :AutoPrune alias_attribute :recycle, :Recycle alias_attribute :action_on_purge, :ActionOnPurge alias_attribute :pool_type, :PoolType alias_attribute :label_type, :LabelType alias_attribute :label_format, :LabelFormat alias_attribute :enabled, :Enabled alias_attribute :scratch_pool_id, :ScratchPoolId alias_attribute :recycle_pool_id, :RecyclePoolId alias_attribute :next_pool_id, :NextPoolId alias_attribute :migration_high_bytes, :MigrationHighBytes alias_attribute :migration_low_bytes, :MigrationLowBytes alias_attribute :migration_time, :MigrationTime has_many :jobs, foreign_key: :PoolId has_many :media, foreign_key: :PoolId + validates_confirmation_of :name + + BOOLEAN_OPTIONS = [['no', 0], ['yes', 1]] + POOL_OPTIONS = [:name, :vol_retention, :use_once, :auto_prune, :recycle, + :max_vols, :max_vol_jobs, :max_vol_files, :max_vol_bytes, :label_format] + + # @return [Array] of all the available pools by name def self.available_options pluck(:Name) end + + # Persists the unpersisted record to bacula via its bacula handler + # + # @return [Boolean] according to the persist status + def submit_to_bacula + return false if !valid? || !ready_for_bacula? + sanitize_names + bacula_handler.deploy_config + end + + # Constructs an array where each element is a line for the Job's bacula config + # + # @return [Array] + def to_bacula_config_array + ['Pool {'] + + options_array.map { |x| " #{x}" } + + ['}'] + end + + # Human readable volume retention + # + # @return [String] the volume's retention in days + def vol_retention_human + "#{vol_retention_days} days" + end + + # volume retention in days + def vol_retention_days + vol_retention / 1.day.to_i + end + + private + + # proxy object for bacula pool handling + def bacula_handler + BaculaPoolHandler.new(self) + end + + # pool names and label formats should only contain alphanumeric values + def sanitize_names + self.name = name.gsub(/[^a-zA-Z0-9]/, '_') + self.label_format = label_format.gsub(/[^a-zA-Z0-9]/, '_') + end + + def options_array + boolean_options = Hash[BOOLEAN_OPTIONS].invert + [ + "Name = \"#{name}\"", + "Volume Retention = #{vol_retention_human}", + "Use Volume Once = #{boolean_options[use_once.to_i]}", + "AutoPrune = #{boolean_options[auto_prune.to_i]}", + "Recycle = #{boolean_options[recycle.to_i]}", + "Maximum Volumes = #{max_vols}", + "Maximum Volume Jobs = #{max_vol_jobs}", + "Maximum Volume Files = #{max_vol_files}", + "Maximum Volume Bytes = #{max_vol_bytes}G", + "Label Format = \"#{label_format}\"", + "Pool Type = Backup" + ] + end + + def ready_for_bacula? + POOL_OPTIONS.all? { |attr| self.send(attr).present? } + end end diff --git a/app/views/admin/pools/_form.html.erb b/app/views/admin/pools/_form.html.erb new file mode 100644 index 0000000..6450261 --- /dev/null +++ b/app/views/admin/pools/_form.html.erb @@ -0,0 +1,25 @@ +<%= bootstrap_form_for(@pool, url: admin_pools_path, method: :post, layout: :horizontal, + label_col: 'col-xs-5', control_col: 'col-xs-6') do |f| %> + <%= f.text_field :name, required: true %> + <%= f.text_field :name_confirmation, required: true %> + <%= f.number_field :vol_retention, label: 'Volume Retention in days', + value: @pool.vol_retention_days || 180, required: true %> + <%= f.select :use_once, options_for_select(Pool::BOOLEAN_OPTIONS), required: true %> + <%= f.select :auto_prune, options_for_select(Pool::BOOLEAN_OPTIONS.reverse), + required: true %> + <%= f.select :recycle, options_for_select(Pool::BOOLEAN_OPTIONS.reverse), + required: true %> + <%= f.number_field :max_vols, required: true, value: 0 %> + <%= f.number_field :max_vol_jobs, min: 0, required: true %> + <%= f.number_field :max_vol_files, min: 0, required: true %> + <%= f.number_field :max_vol_bytes, label: 'Max Volume GB', min: 0, required: true %> + <%= f.text_field :label_format, required: true %> + +
+
+ <%= f.submit class: 'btn btn-success' %> +
+
+<% end %> + +<%= link_to 'Cancel', admin_pools_path, class: 'btn btn-danger', role: 'button' %> diff --git a/app/views/admin/pools/_pool.html.erb b/app/views/admin/pools/_pool.html.erb new file mode 100644 index 0000000..6300b7d --- /dev/null +++ b/app/views/admin/pools/_pool.html.erb @@ -0,0 +1,14 @@ + + <%= link_to "##{pool.id}", admin_pool_path(pool) %> + <%= link_to pool.name, admin_pool_path(pool) %> + <%= pool.max_vols %> + <%= yes_no(pool.use_once) %> + <%= pool.vol_retention_human %> + <%= pool.max_vol_jobs %> + <%= pool.max_vol_files %> + <%= number_to_human_size pool.max_vol_bytes %> + <%= yes_no(pool.auto_prune) %> + <%= pool.label_format %> + <%= yes_no(pool.recycle) %> + <%= pool.pool_type %> + diff --git a/app/views/admin/pools/index.html.erb b/app/views/admin/pools/index.html.erb new file mode 100644 index 0000000..3ee79de --- /dev/null +++ b/app/views/admin/pools/index.html.erb @@ -0,0 +1,37 @@ +
+ <%= link_to new_admin_pool_path, class: "btn btn-default", role: "button" do %> + + New Pool + <% end %> +
+ +

Pools

+ +
+
+
+ + + + + + + + + + + + + + + + + + + + <%= render partial: 'pool', collection: @pools %> + +
idNameMax VolumesUse OnceVolume RetentionMax Volume JobsMax Volume FilesMax Volume BytesAuto PruneLabel FormatRecyclePool Type
+
+
+
diff --git a/app/views/admin/pools/new.html.erb b/app/views/admin/pools/new.html.erb new file mode 100644 index 0000000..67d7b43 --- /dev/null +++ b/app/views/admin/pools/new.html.erb @@ -0,0 +1,11 @@ +
+
+
+
+

New Pool

+ + <%= render partial: 'form' %> +
+
+
+
diff --git a/app/views/shared/_nav.html.erb b/app/views/shared/_nav.html.erb index bfacf33..ae04748 100644 --- a/app/views/shared/_nav.html.erb +++ b/app/views/shared/_nav.html.erb @@ -1,76 +1,79 @@ diff --git a/config/routes.rb b/config/routes.rb index 34d5ee1..d652ea1 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1,91 +1,93 @@ Rails.application.routes.draw do root 'application#index' post 'grnet' => 'application#grnet' get 'institutional' => 'application#institutional' match 'vima', to: 'application#vima', :via => [:get, :post] get 'logout' => 'application#logout' resources :clients, only: [:index, :show] do member do get :jobs get :logs get :stats post :stats get :users get :restore post :run_restore post :restore_selected end collection do post :index end end resources :clients, only: [], param: :client_id do member do get :tree end end resources :hosts, only: [:new, :create, :show, :edit, :update, :destroy] do member do post :submit_config post :disable delete :revoke end resources :jobs, only: [:new, :create, :show, :edit, :update, :destroy] do member do patch :toggle_enable post :backup_now end end resources :filesets, only: [:show, :new, :create, :edit, :update, :destroy] resources :schedules, only: [:show, :new, :edit, :create, :update, :destroy] end namespace :admin do match '/', to: 'base#index', via: [:get, :post] get '/login' => 'base#login', as: :login resources :settings, only: [:index, :new, :create, :edit, :update] do member do delete :reset end end resources :clients, only: [:index, :show] do member do get :jobs get :logs get :stats post :stats get :configuration post :disable post :block post :unblock delete :revoke end end resources :hosts, only: [:show] do collection do get :unverified end member do post :verify end end resources :users, only: [:index, :new, :create, :show, :edit, :update] do member do patch :ban patch :unban end end + + resources :pools, only: [:index, :new, :create, :show, :edit, :update] end end diff --git a/spec/routing/admin/pools_routing_spec.rb b/spec/routing/admin/pools_routing_spec.rb new file mode 100644 index 0000000..a49827d --- /dev/null +++ b/spec/routing/admin/pools_routing_spec.rb @@ -0,0 +1,32 @@ +require 'spec_helper' + +describe Admin::PoolsController do + it 'routes GET /admin/pools' do + expect(get('/admin/pools')).to route_to(controller: 'admin/pools', action: 'index') + end + + it 'routes GET /admin/pools/new' do + expect(get('/admin/pools/new')). + to route_to(controller: 'admin/pools', action: 'new') + end + + it 'routes GET /admin/pools/1' do + expect(get('/admin/pools/1')). + to route_to(controller: 'admin/pools', action: 'show', id: '1') + end + + it 'routes GET /admin/pools/1/edit' do + expect(get('/admin/pools/1/edit')). + to route_to(controller: 'admin/pools', action: 'edit', id: '1') + end + + it 'routes POST /admin/pools' do + expect(post('/admin/pools')). + to route_to(controller: 'admin/pools', action: 'create') + end + + it 'routes PATCH /admin/pools/1' do + expect(patch('/admin/pools/1')). + to route_to(controller: 'admin/pools', action: 'update', id: '1') + end +end