app/models/miq_widget.rb
# Dashboard widget
#
class MiqWidget < ApplicationRecord
include ReadOnlyMixin
default_value_for :enabled, true
default_value_for :read_only, false
DEFAULT_ROW_COUNT = 5
IMPORT_CLASS_NAMES = %w[MiqWidget].freeze
belongs_to :resource, :polymorphic => true
belongs_to :miq_schedule
belongs_to :user
belongs_to :miq_task
has_many :miq_widget_contents, :dependent => :destroy
has_many :miq_widget_shortcuts, :dependent => :destroy
has_many :miq_shortcuts, :through => :miq_widget_shortcuts
validates_presence_of :title, :description
validates :description, :uniqueness_when_changed => true
VALID_CONTENT_TYPES = %w[report chart menu]
validates_inclusion_of :content_type, :in => VALID_CONTENT_TYPES, :message => "should be one of #{VALID_CONTENT_TYPES.join(", ")}"
serialize :visibility
serialize :options
scope :with_content_type, ->(type) { where(:content_type => type) }
include ImportExport
include UuidMixin
include YamlImportExportMixin
acts_as_miq_set_member
WIDGET_REPORT_SOURCE = "Generated for widget".freeze
before_destroy :destroy_schedule, :prevent_orphaned_dashboard
def destroy_schedule
miq_schedule.destroy if miq_schedule
end
def prevent_orphaned_dashboard
dependent_sets = MiqWidgetSet.select(:name, :set_data).all.select { |set| set.has_widget_id_member?(id) }
unless dependent_sets.empty?
errors.add(:base, _("Widget: #{title}(#{id}) must be removed from these dashboards before it can be removed: #{dependent_sets.collect(&:name).join(", ")}"))
throw(:abort)
end
end
virtual_column :status, :type => :string, :uses => :miq_task
virtual_delegate :status_message, :to => "miq_task.message", :allow_nil => true, :default => "Unknown", :type => :string
virtual_delegate :queued_at, :to => "miq_task.created_on", :allow_nil => true, :type => :datetime
virtual_column :last_run_on, :type => :datetime, :uses => :miq_schedule
def row_count(row_count_param = nil)
row_count_param.try(:to_i) || options.try(:[], :row_count) || DEFAULT_ROW_COUNT
end
alias_attribute :name, :description
def last_run_on
last_generated_content_on || (miq_schedule && miq_schedule.last_run_on)
end
delegate :next_run_on, :to => :miq_schedule, :allow_nil => true
def status
if miq_task.nil?
return N_("None") if last_run_on.nil?
return N_("Complete")
end
miq_task.human_status
end
def create_task(num_targets, userid = User.current_userid)
userid ||= "system"
context_data = {:targets => num_targets, :complete => 0}
miq_task = MiqTask.create(
:name => "Generate Widget: '#{title}'",
:state => MiqTask::STATE_QUEUED,
:status => MiqTask::STATUS_OK,
:message => "Task has been queued",
:pct_complete => 0,
:userid => userid,
:context_data => context_data
)
_log.info("Created MiqTask ID: [#{miq_task.id}], Name: [#{miq_task.name}] for: [#{num_targets}] groups")
self.miq_task_id = miq_task.id
save!
miq_task
end
def generate_content_options(group, users)
content_option_generator.generate(group, users, timezone_matters?)
end
def timeout_stalled_task
return unless miq_task && miq_task.state != MiqTask::STATE_FINISHED &&
!MiqQueue.where(:method_name => "generate_content",
:class_name => self.class.name,
:instance_id => id).any?(&:unfinished?)
miq_task.update_status(MiqTask::STATE_FINISHED, MiqTask::STATUS_TIMEOUT, "Timed out stalled task.")
end
def queue_generate_content_for_users_or_group(*args)
callback = {}
if miq_task_id
cb = {:class_name => self.class.name, :instance_id => id, :method_name => :generate_content_complete_callback}
callback[:miq_callback] = cb
end
MiqQueue.create_with(callback).put_unless_exists(
:queue_name => "reporting",
:role => "reporting",
:zone => nil, # any zone
:class_name => self.class.to_s,
:instance_id => id,
:msg_timeout => 3600,
:method_name => "generate_content",
:args => [*args]
)
end
def generate_content_complete_callback(status, _message, _result)
_log.info("Widget ID: [#{id}], MiqTask ID: [#{miq_task_id}], Status: [#{status}]")
miq_task.lock(:exclusive) do |locked_miq_task|
if MiqTask.status_error?(status)
locked_miq_task.context_data[:error] ||= 0
locked_miq_task.context_data[:error] += 1
end
if MiqTask.status_timeout?(status)
locked_miq_task.context_data[:timeout] ||= 0
locked_miq_task.context_data[:timeout] += 1
end
locked_miq_task.context_data[:complete] ||= 0
locked_miq_task.context_data[:complete] += 1
locked_miq_task.pct_complete = 100 * locked_miq_task.context_data[:complete] / locked_miq_task.context_data[:targets]
if locked_miq_task.context_data[:complete] == locked_miq_task.context_data[:targets]
task_status = MiqTask::STATUS_OK
task_status = MiqTask::STATUS_TIMEOUT if locked_miq_task.context_data.key?(:timeout)
task_status = MiqTask::STATUS_ERROR if locked_miq_task.context_data.key?(:error)
locked_miq_task.update_status(MiqTask::STATE_FINISHED, task_status, generate_content_complete_message)
generate_content_complete!
else
locked_miq_task.message = generate_content_update_message
end
locked_miq_task.save!
end
end
def generate_content_complete!
update!(:last_generated_content_on => Time.now.utc)
end
def generate_content_complete_message
message = "Widget Generation for #{miq_task.context_data[:targets]} groups complete"
message << " (#{miq_task.context_data[:error]} in Error)" if miq_task.context_data.key?(:error)
message << " (#{miq_task.context_data[:timeout]} Timed Out)" if miq_task.context_data.key?(:timeout)
message
end
def generate_content_update_message
message = "Widget Generation for #{miq_task.context_data[:complete]} of #{miq_task.context_data[:targets]} groups Complete"
message << " (#{miq_task.context_data[:error]} in Error)" if miq_task.context_data.key?(:error)
message << " (#{miq_task.context_data[:timeout]} Timed Out)" if miq_task.context_data.key?(:timeout)
message
end
def log_prefix
"Widget: [#{title}] ID: [#{id}]"
end
def queue_generate_content
return if content_type == "menu"
# Called from schedule
unless enabled?
_log.info("#{log_prefix} is disabled, content will NOT be generated")
return
end
group_hash_visibility_agnostic = grouped_subscribers
if group_hash_visibility_agnostic.empty?
_log.info("#{log_prefix} has no subscribers, content will NOT be generated")
return
end
MiqPreloader.preload(group_hash_visibility_agnostic.keys, [:miq_user_role])
group_hash = group_hash_visibility_agnostic.select { |k, _v| available_for_group?(k) } # Process users grouped by LDAP group membership of whether they have RBAC
if group_hash.length == 0
_log.info("#{log_prefix} is not subscribed, content will NOT be generated")
return
end
if ::Settings.product.report_sync
group_hash.each do |g, u|
options = generate_content_options(g, u)
generate_content(*options)
end
return
end
timeout_stalled_task
task = MiqTask.find_by(
:name => "Generate Widget: '#{title}'",
:userid => User.current_userid || 'system',
:state => %w[Queued Active]
)
if task
_log.warn("#{log_prefix} Skipping task creation for widget content generation. Task with name \"Generate Widget: '#{title}' already exists\"")
else
task = create_task(group_hash.length)
_log.info("#{log_prefix} Queueing Content Generation")
group_hash.each do |g, u|
options = generate_content_options(g, u)
queue_generate_content_for_users_or_group(*options)
end
end
task.id
end
def generate_content(klass, group_description, userids, timezones = nil)
return if content_type == "menu"
miq_task.state_active if miq_task
content_generator.generate(self, klass, group_description, userids, timezones)
end
def generate_one_content_for_group(group, timezone)
_log.info("#{log_prefix} for [#{group.class}] [#{group.name}]...")
begin
content_type_klass = "MiqWidget::#{content_type.capitalize}Content".constantize
rescue NameError
_log.error("#{log_prefix} Unsupported content type '#{content_type}'")
return
end
begin
if content_type_klass.based_on_miq_report?
report = generate_report(group)
miq_report_result = generate_report_result(report, group, timezone)
end
data = content_type_klass.new(:report => report, :resource => resource, :timezone => timezone, :widget_options => options).generate(group)
content = find_or_build_contents_for_user(group, nil, timezone)
content.update!(:miq_report_result => miq_report_result, :contents => data, :miq_group_id => group.id)
rescue => error
_log.error("#{log_prefix} Failed for [#{group.class}] [#{group.name}] with error: [#{error.class.name}] [#{error}]")
_log.log_backtrace(error)
return
end
_log.info("#{log_prefix} for [#{group.class}] [#{group.name}]...Complete")
content
end
def generate_one_content_for_user(group, userid)
_log.info("#{log_prefix} for group: [#{group.name}] users: [#{userid}]...")
user = userid
user = User.in_my_region.find_by(:userid => userid) if userid.kind_of?(String)
if user.nil?
_log.error("#{log_prefix} User #{userid} was not found")
return
end
timezone = user.get_timezone
if timezone.nil?
_log.warn("#{log_prefix} No timezone provided for #{userid}! UTC will be used.")
timezone = "UTC"
end
begin
content_type_klass = "MiqWidget::#{content_type.capitalize}Content".constantize
rescue NameError
_log.error("#{log_prefix} Unsupported content type '#{content_type}'")
return
end
begin
if content_type_klass.based_on_miq_report?
report = generate_report(group, user)
miq_report_result = generate_report_result(report, user, timezone)
end
data = content_type_klass.new(:report => report, :resource => resource, :timezone => timezone, :widget_options => options).generate(user)
content = find_or_build_contents_for_user(group, user, timezone)
content.update!(:miq_report_result => miq_report_result, :contents => data, :miq_group_id => group.id, :user_id => user.id)
rescue => error
_log.error("#{log_prefix} Failed for [#{user.class}] [#{user.name}] with error: [#{error.class.name}] [#{error}]")
_log.log_backtrace(error)
return
end
_log.info("#{log_prefix} for [#{group.name}] [#{userid}]...Complete")
content
end
def generate_report(group, user = nil)
rpt = resource.dup
opts = {:miq_group_id => group.id}
opts[:userid] = user.userid if user
rpt.generate_table(opts)
rpt
end
def generate_report_result(rpt, owner, timezone = nil)
name = owner.respond_to?(:userid) ? owner.userid : owner.name
group = owner.kind_of?(MiqGroup) ? owner : owner.try(:current_group)
userid_for_result = "widget_id_#{id}|#{name}|schedule"
MiqReportResult.purge_for_user(:userid => userid_for_result)
rpt.build_create_results(:userid => userid_for_result, :report_source => WIDGET_REPORT_SOURCE, :timezone => timezone, :miq_group_id => group&.id)
end
def find_or_build_contents_for_user(group, user, timezone = nil)
timezone = "UTC" if timezone && !timezone_matters?
settings_for_build = {:miq_group_id => group.id}
settings_for_build[:user_id] = user.id if user
settings_for_build[:timezone] = timezone if timezone
contents = miq_widget_contents.find_by(settings_for_build) || miq_widget_contents.build(settings_for_build)
contents.tap { |c| c.update!(:updated_at => Time.now.utc) }
end
# TODO: group/user support
def create_initial_content_for_user(user, group = nil)
return unless contents_for_user(user).blank? && content_type != "menu" # Menu widgets have no content
user = self.class.get_user(user)
group = self.class.get_group(group)
group ||= user.current_group
options = generate_content_options(group, [user])
if ::Settings.product.report_sync
generate_content(*options)
else
timeout_stalled_task
if MiqTask.exists?(:name => "Generate Widget: '#{title}'",
:userid => user.userid,
:state => %w[Queued Active])
_log.warn("#{log_prefix} Skipping task creation for widget content generation. Task with name \"Generate Widget: '#{title}' already exists\"")
else
create_task(1, user.userid)
queue_generate_content_for_users_or_group(*options)
end
end
end
def contents_for_user(user)
user = self.class.get_user(user)
timezone = timezone_matters? ? user.get_timezone : "UTC"
conditions = {:miq_group_id => user.current_group.id}
conditions[:user_id] = user.id
conditions[:timezone] = timezone
contents = miq_widget_contents.find_by(conditions)
conditions.delete(:user_id)
contents ||= miq_widget_contents.find_by(conditions)
if contents.nil?
_log.warn("No contents found for Widget: '#{title}' Group: #{user.current_group.description} in Timezone '#{timezone}'. Attempting to get widget's contents from any Timezone ...")
conditions.delete(:timezone)
contents = miq_widget_contents.find_by(conditions)
end
contents
end
def last_run_on_for_user(user)
contents = contents_for_user(user)
return nil if contents.nil?
contents.miq_report_result.nil? ? contents.updated_at : contents.miq_report_result.last_run_on
end
def grouped_users_by_id
id_groups = Hash.new { |h, k| h[k] = [] }
memberof.compact.each_with_object(id_groups) do |ws, h|
h[ws.group_id] << ws.userid unless ws.userid.blank? || ws.group_id.blank?
end
end
def grouped_subscribers
grouped_users = grouped_users_by_id
groups_by_id = MiqGroup.in_my_region.where(:id => grouped_users.keys).index_by(&:id)
users_by_userid = User.in_my_region.where(:userid => grouped_users.values.flatten.uniq).index_by(&:userid)
grouped_users.each_with_object({}) do |(k, v), h|
user_objs = users_by_userid.values_at(*v).reject(&:blank?)
h[groups_by_id[k]] = user_objs if user_objs.present?
end
end
def available_for_group?(group)
return false unless group
has_visibility?(:roles, group.miq_user_role_name) || has_visibility?(:groups, group.description)
end
def self.available_for_user(user)
user = get_user(user)
role = user.miq_user_role_name
group = user.current_group.description
# Return all widgets that either has this user's role or is allowed for all roles, or has this user's group
all.select do |w|
w.has_visibility?(:roles, role) || w.has_visibility?(:groups, group)
end
end
def self.available_for_group(group)
group = get_group(group)
role = group.miq_user_role_name
# Return all widgets that either has this group's role or is allowed for all roles.
all.select do |w|
w.has_visibility?(:roles, role) || w.has_visibility?(:groups, group.description)
end
end
def self.available_for_all_roles
all.select { |w| w.visibility.key?(:roles) && w.visibility[:roles].include?("_ALL_") }
end
def has_visibility?(key, value)
visibility.kind_of?(Hash) && visibility.key?(key) && (visibility[key].include?(value) || visibility[key].include?("_ALL_"))
end
def self.get_user(user)
if user.kind_of?(String)
original = user
user = User.in_my_region.find_by_userid(user)
_log.warn("Unable to find user '#{original}'") if user.nil?
end
user
end
def self.get_group(group)
return nil if group.nil?
original = group
case group
when String
group = MiqGroup.in_my_region.find_by(:description => group)
when Integer
group = MiqGroup.in_my_region.find_by(:id => group)
end
_log.warn("Unable to find group '#{original}'") if group.nil?
group
end
def self.sync_from_dir
Vmdb::Plugins.miq_widgets_content.sort.each { |f| sync_from_file(f) }
end
def self.sync_from_file(filename)
attrs = YAML.load_file(filename)
sync_from_hash(attrs.merge("filename" => filename))
end
def self.sync_from_hash(attrs)
attrs.delete("id")
filename = attrs.delete("filename")
rname = attrs.delete("resource_name")
if rname && attrs["resource_type"]
klass = attrs.delete("resource_type").constantize
attrs["resource"] = klass.find_by(:name => rname)
raise _("Unable to find %{class} with name %{name}") % {:class => klass, :name => rname} unless attrs["resource"]
end
schedule_info = attrs.delete("miq_schedule_options")
widget = find_by(:description => attrs["description"])
if widget
if filename
$log.info("Widget: [#{widget.description}] file has been updated on disk, synchronizing with model")
["enabled", "visibility"].each { |a| attrs.delete(a) } # Don't updates these because they may have been modofoed by the end user.
widget.update!(attrs)
end
else
$log.info("Widget: [#{attrs["description"]}] file has been added to disk, adding to model")
widget = create!(attrs)
end
widget.sync_schedule(schedule_info)
widget
end
def filter_for_schedule
{"=" => {"field" => "MiqWidget-id", "value" => id}}
end
def sync_schedule(schedule_info)
return if schedule_info.nil?
sched = miq_schedule
return sched unless sched.nil?
server_tz = MiqServer.my_server.server_timezone
value = schedule_info.fetch_path(:run_at, :interval, :value)
unit = schedule_info.fetch_path(:run_at, :interval, :unit)
if unit == "daily"
sched_time = (Time.now.in_time_zone(server_tz) + 1.day).beginning_of_day
elsif unit == "hourly"
ts = (Time.now.utc + 1.hour).iso8601
ts[14..18] = "00:00"
sched_time = ts.to_time(:utc).in_time_zone(server_tz)
else
raise _("Unsupported interval '%{interval}'") % {:interval => interval}
end
sched = existing_schedule
sched ||= MiqSchedule.create!(
:name => description,
:description => description,
:sched_action => {:method => "generate_widget"},
:filter => MiqExpression.new(filter_for_schedule),
:resource_type => self.class.name,
:run_at => {
:interval => {:value => value, :unit => unit},
:tz => server_tz,
:start_time => sched_time
}
)
self.miq_schedule = sched
save!
_log.info("Created schedule for Widget: [#{title}]")
_log.debug("Widget: [#{title}] created schedule: [#{sched.inspect}]")
sched
end
def existing_schedule
return nil if (sched = MiqSchedule.find_by(:name => description)).nil?
# return existing sheduler if filter referr to the same widget
return sched if sched.filter.exp == filter_for_schedule
# change name of existed schedule in case it is in use
suffix = Time.new.utc.to_s
_log.warn("Schedule #{sched.name} already exists, renaming it to `#{sched.name} #{suffix}`")
sched.update(:name => "#{sched.name} #{suffix}", :description => "#{sched.description} #{suffix}")
nil
end
def self.seed
sync_from_dir
end
def save_with_shortcuts(shortcuts) # [[<shortcut.id>, <widget_shortcut.description>], ...]
transaction do
ws = [] # Create an array of widget shortcuts
shortcuts.each_with_index do |s, s_idx|
ws.push(MiqWidgetShortcut.new(:sequence => s_idx, :description => s.last, :miq_shortcut_id => s.first))
end
self.miq_widget_shortcuts = ws
save # .save! raises exception if validate_uniqueness fails
end
errors.empty? # True if no errors
end
def delete_legacy_contents_for_group(group)
MiqWidgetContent.where(:miq_widget_id => id, :miq_group_id => group.id, :user_id => nil).destroy_all
end
# default: timezone does matter
# options[:timezone_matters] == false will skip it
# TODO: detect date field in the report?
def timezone_matters?
return true unless options
options.fetch(:timezone_matters, true)
end
def self.display_name(number = 1)
n_('Widget', 'Widgets', number)
end
private
def content_generator
@content_generator ||= MiqWidget::ContentGenerator.new
end
def content_option_generator
@content_option_generator ||= MiqWidget::ContentOptionGenerator.new
end
end