require 'sinatra/base' require 'sinatra/reloader' require 'sequel' require 'json' require 'uri' module Perus::Server class App < Sinatra::Application #---------------------- # config #---------------------- helpers Helpers configure do set :root, File.join(__dir__) end configure :development do register Sinatra::Reloader end before do load_site_information end #---------------------- # admin pages #---------------------- extend Admin admin :system admin :group admin :alert admin :config, true admin :script, true # static admin index page get '/admin' do redirect "#{url_prefix}admin/systems" end post '/admin/scripts/:id/commands' do protected! script = Script.with_pk!(params['id']) script_command = ScriptCommand.new script_command.script_id = params['id'] script_command.order = script.largest_order + 1 command_config = CommandConfig.create_with_params(params) script_command.command_config_id = command_config.id begin script_command.save rescue if script_command.command_config_id CommandConfig.with_pk!(script_command.command_config_id).destroy end end redirect "#{url_prefix}admin/scripts/#{params['id']}" end post '/admin/scripts/:script_id/commands/:id' do protected! script_command = ScriptCommand.with_pk!(params['id']) if params['action'] == 'Delete' script_command.destroy elsif params['action'] == 'Update' script_command.command_config.update_options!(params) end redirect "#{url_prefix}admin/scripts/#{params['script_id']}" end post '/admin/configs/:id/metrics' do protected! config = Config.with_pk!(params['id']) config_metric = ConfigMetric.new config_metric.config_id = params['id'] config_metric.order = config.largest_order + 1 command_config = CommandConfig.create_with_params(params) config_metric.command_config_id = command_config.id begin config_metric.save rescue if config_metric.command_config_id CommandConfig.with_pk!(config_metric.command_config_id).destroy end end redirect "#{url_prefix}admin/configs/#{params['id']}" end post '/admin/configs/:config_id/metrics/:id' do protected! config_metric = ConfigMetric.with_pk!(params['id']) if params['action'] == 'Delete' config_metric.destroy elsif params['action'] == 'Update' config_metric.command_config.update_options!(params) end redirect "#{url_prefix}admin/configs/#{params['config_id']}" end get '/admin/stats' do protected! @stats = Stats.new @queue_length = Server.ping_queue.length erb :stats end #---------------------- # API #---------------------- # csv for graphs shown on system page get '/systems/:id/values' do protected! system = System.with_pk!(params['id']) metrics = params[:metrics].to_s.split(',') # find all values for the requested metrics dataset = system.values_dataset.where(metric: metrics) # the graphing library requires multiple series to be provided in # row format (date, series 1, series 2 etc) so each value is # grouped into a timestamp update window updates = dataset.all.group_by(&:timestamp) # loop from start to end generating the response csv timestamps = updates.keys.sort csv = "Date,#{params['metrics']}\n" timestamps.each do |timestamp| date = Time.at(timestamp).strftime('%Y-%m-%d %H:%M:%S') csv << date << ',' values = Array.new(metrics.length) records = updates[timestamp] records.each do |record| values[metrics.index(record.metric)] = record.num_value end csv << values.join(',') << "\n" end csv end # receive data from a client post '/systems/:id/ping' do timestamp = Time.now.to_i ping_params = params.dup if request.ip == '127.0.0.1' && ENV['RACK_ENV'] == 'production' system_ip = request.env['HTTP_X_FORWARDED_FOR'] else system_ip = request.ip end Server.ping_queue << Proc.new do # update the system with its last known ip and update time system = System.with_pk!(ping_params['id']) system.last_updated = timestamp system.ip = system_ip system.save # errors is either nil or a hash of the format - module: [err, ...] system.save_metric_errors(ping_params, timestamp) # add each new value, a later process cleans up old values system.save_values(ping_params, timestamp) # save action return values and prevent them from running again system.save_actions(ping_params, timestamp) end content_type :json {success: true}.to_json end # system config get '/systems/:id/config' do system = System.with_pk!(params['id']) content_type :json system.config_hash.to_json end # config of system based on request ip get '/systems/config_for_ip' do if request.ip == '127.0.0.1' && ENV['RACK_ENV'] == 'production' ip = request.env['HTTP_X_FORWARDED_FOR'] else ip = request.ip end system = System.where(ip: ip).first content_type :json system.config_hash.to_json end # render all errors in html to replace the shortened subset on the system page get '/systems/:id/errors' do protected! system = System.with_pk!(params['id']) errors = system.collection_errors erb :errors, layout: false, locals: {errors: errors} end # clear collection errors delete '/systems/:id/errors' do protected! system = System.with_pk!(params['id']) system.collection_errors.each(&:delete) redirect "#{url_prefix}systems/#{system.id}" end # create a new action post '/systems/:id/actions' do protected! Action.add(params['id'], params) redirect "#{url_prefix}systems/#{params['id']}#actions" end # create an action for all systems in a group post '/groups/:id/systems/actions' do protected! group = Group.with_pk!(params['id']) group.systems.each do |system| Action.add(system.id, params) end redirect "#{url_prefix}groups/#{params['id']}/systems" end # delete completed actions in a group delete '/groups/:id/systems/actions' do protected! group = Group.with_pk!(params['id']) group.systems.each do |system| system.actions.each do |action| next if action.timestamp.nil? action.destroy end end redirect "#{url_prefix}groups/#{params['id']}/systems" end # create an action for all systems post '/systems/actions' do protected! System.each do |system| Action.add(system.id, params) end redirect "#{url_prefix}systems" end # delete all completed actions delete '/systems/actions' do protected! Action.each do |action| next if action.timestamp.nil? action.destroy end redirect "#{url_prefix}systems" end # delete an action. deletion also clears any uploaded files. delete '/systems/:system_id/actions/:id' do protected! action = Action.with_pk!(params['id']) action.destroy redirect "#{url_prefix}systems/#{params['system_id']}#actions" end #---------------------- # frontend #---------------------- # overview get '/' do protected! systems = System.all @alerts = Alert.all.sort_by(&:severity_level).reverse erb :index end # list of systems get '/systems' do protected! @systems = System.all.group_by(&:orientation) @title = 'All Systems' @scripts = Script.all @action_url = "systems/actions" erb :systems end # list of systems by group get '/groups/:id/systems' do protected! group = Group.with_pk!(params['id']) @systems = group.systems_dataset.order_by(:name).all.group_by(&:orientation) @title = group.name @scripts = Script.all @action_url = "groups/#{params['id']}/systems/actions" erb :systems end # info page for a system get '/systems/:id' do protected! @system = System.with_pk!(params['id']) @uploads = @system.upload_urls metrics = @system.metrics # we're only interested in the latest value for string metrics @str_metrics = {} metrics.select(&:string?).each do |metric| value = @system.latest(metric.name) name = "#{metric.name.titlecase}:" @str_metrics[name] = value ? value.str_value : '' end # numeric values are grouped together by their first name component # and drawn on a graph. e.g cpu_all and cpu_chrome are shown together num_metrics = metrics.select(&:numeric?).map(&:name) @num_metrics = num_metrics.group_by {|n| n.split('_')[0]} # make links clickable @links = @system.links.to_s.gsub("\n", "
") URI::extract(@links).each {|uri| @links.gsub!(uri, %Q{#{uri}})} # last updated is a timestamp, conver if @system.last_updated @last_updated = Time.at(@system.last_updated).ctime else @last_updated = 'never updated' end @errors = @system.collection_errors_dataset.limit(5).all @total_error_count = @system.collection_errors_dataset.count @scripts = Script.all erb :system end # helper to make uploads publicly accessible get '/uploads/*' do protected! path = params['splat'][0] raise 'Invalid path' if path.include?('..') full_path = File.join(Server.options.uploads_dir, path) send_file full_path end end end