greenhat 0.3.4 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. checksums.yaml +4 -4
  2. data/bin/greenhat +2 -6
  3. data/lib/greenhat/accessors/disk.rb +1 -3
  4. data/lib/greenhat/accessors/gitlab.rb +7 -2
  5. data/lib/greenhat/accessors/memory.rb +1 -1
  6. data/lib/greenhat/archive.rb +19 -8
  7. data/lib/greenhat/cli.rb +35 -135
  8. data/lib/greenhat/entrypoint.rb +170 -0
  9. data/lib/greenhat/host.rb +25 -37
  10. data/lib/greenhat/settings.rb +33 -5
  11. data/lib/greenhat/shell/args.rb +22 -9
  12. data/lib/greenhat/shell/faststats.rb +23 -3
  13. data/lib/greenhat/shell/field_helper.rb +1 -1
  14. data/lib/greenhat/shell/filter_help.rb +217 -162
  15. data/lib/greenhat/shell/gitlab.rb +1 -0
  16. data/lib/greenhat/shell/log.rb +150 -25
  17. data/lib/greenhat/shell/markdown.rb +21 -25
  18. data/lib/greenhat/shell/old_search_helper.rb +54 -0
  19. data/lib/greenhat/shell/page.rb +1 -1
  20. data/lib/greenhat/shell/pipe.rb +31 -0
  21. data/lib/greenhat/shell/platform.rb +28 -0
  22. data/lib/greenhat/shell/query.rb +378 -0
  23. data/lib/greenhat/shell/report.rb +76 -24
  24. data/lib/greenhat/shell/shell_helper.rb +42 -393
  25. data/lib/greenhat/shell.rb +19 -4
  26. data/lib/greenhat/thing/file_types.rb +51 -1
  27. data/lib/greenhat/thing/formatters/api_json.rb +4 -2
  28. data/lib/greenhat/thing/formatters/bracket_log.rb +1 -1
  29. data/lib/greenhat/thing/formatters/colon_split_strip.rb +2 -2
  30. data/lib/greenhat/thing/formatters/dotenv.rb +1 -1
  31. data/lib/greenhat/thing/formatters/format.rb +0 -11
  32. data/lib/greenhat/thing/formatters/free_m.rb +2 -2
  33. data/lib/greenhat/thing/formatters/json.rb +43 -15
  34. data/lib/greenhat/thing/formatters/json_shellwords.rb +3 -2
  35. data/lib/greenhat/thing/formatters/kube_json.rb +3 -2
  36. data/lib/greenhat/thing/formatters/multiline_json.rb +1 -1
  37. data/lib/greenhat/thing/formatters/nginx.rb +11 -3
  38. data/lib/greenhat/thing/formatters/table.rb +3 -3
  39. data/lib/greenhat/thing/formatters/time_space.rb +0 -16
  40. data/lib/greenhat/thing/helpers.rb +12 -11
  41. data/lib/greenhat/thing/info_format.rb +4 -4
  42. data/lib/greenhat/thing/kind.rb +5 -0
  43. data/lib/greenhat/thing/super_log.rb +0 -101
  44. data/lib/greenhat/thing.rb +31 -25
  45. data/lib/greenhat/version.rb +1 -1
  46. data/lib/greenhat/views/api.slim +55 -0
  47. data/lib/greenhat/views/chart.slim +42 -0
  48. data/lib/greenhat/views/chart_template.slim +31 -0
  49. data/lib/greenhat/views/chartkick.js +21 -0
  50. data/lib/greenhat/views/css.slim +47 -0
  51. data/lib/greenhat/views/gitaly.slim +53 -0
  52. data/lib/greenhat/views/headers.slim +16 -0
  53. data/lib/greenhat/views/index-old.slim +51 -0
  54. data/lib/greenhat/views/index.slim +14 -14
  55. data/lib/greenhat/views/info.slim +17 -18
  56. data/lib/greenhat/views/production.slim +55 -0
  57. data/lib/greenhat/views/sidekiq.slim +55 -0
  58. data/lib/greenhat/views/time.slim +63 -0
  59. data/lib/greenhat/views/workhorse.slim +16 -0
  60. data/lib/greenhat/web/api.rb +94 -0
  61. data/lib/greenhat/web/chartkick_shim.rb +14 -0
  62. data/lib/greenhat/web/faststats.rb +44 -0
  63. data/lib/greenhat/web/gitaly.rb +65 -0
  64. data/lib/greenhat/web/helpers.rb +198 -0
  65. data/lib/greenhat/web/production.rb +104 -0
  66. data/lib/greenhat/web/sidekiq.rb +73 -0
  67. data/lib/greenhat/web/stats_helpers.rb +74 -0
  68. data/lib/greenhat/web/time.rb +36 -0
  69. data/lib/greenhat/web/workhorse.rb +43 -0
  70. data/lib/greenhat/web.rb +63 -19
  71. data/lib/greenhat.rb +2 -0
  72. metadata +74 -5
@@ -0,0 +1,378 @@
1
+ # NameSpace
2
+ module GreenHat
3
+ # Query Handlers
4
+ # rubocop:disable Metrics/ModuleLength
5
+ module Query
6
+ # Main Entry Point for Filtering
7
+ def self.start(files, flags = {}, args = {})
8
+ # Convert to Things
9
+ files = ShellHelper.find_things(files, flags).select(&:query?)
10
+
11
+ # Ignore Archive/Host Dividers
12
+ if flags[:combine]
13
+ results = files.reject(&:blank?).map(&:data).flatten.compact
14
+ Query.filter(results, flags, args)
15
+ else
16
+ # Iterate and Preserve Archive/Host Index
17
+ files.each_with_object({}) do |file, obj|
18
+ # Ignore Empty Results / No Thing
19
+ next if file&.blank?
20
+
21
+ # Include Total Count in Name
22
+ results = Query.filter(file.data, flags, args)
23
+ duration = calculate_duration(results)
24
+
25
+ title = [
26
+ file.friendly_name,
27
+ " #{results.count}".pastel(:bright_black)
28
+
29
+ ]
30
+
31
+ # Append Duration
32
+ title.push(" #{duration.pastel(:cyan, :dim)}") unless duration.blank?
33
+
34
+ # Save unless empty
35
+ obj[title.join] = results unless results.count.zero?
36
+
37
+ obj
38
+ end
39
+ end
40
+ end
41
+
42
+ def self.calculate_duration(results)
43
+ # Skip for Pluck
44
+ only_with_time = results.select { |x| x.instance_of?(Hash) && x.key?(:time) }
45
+
46
+ # If slice is used ignore
47
+ return nil if only_with_time.empty?
48
+
49
+ sorted = only_with_time.map(&:time).sort
50
+ humanize_time(sorted.first, sorted.last)
51
+ end
52
+
53
+ # Replace TimeDifference with https://stackoverflow.com/a/4136485/1678507
54
+ def self.humanize_time(time_start, time_end, increments = 2)
55
+ miliseconds = (time_end - time_start) * 1000
56
+
57
+ list = [[1000, :ms], [60, :s], [60, :m], [24, :h]].map do |count, name|
58
+ next unless miliseconds.positive?
59
+
60
+ miliseconds, n = miliseconds.divmod(count)
61
+
62
+ "#{n.to_i}#{name}" unless n.to_i.zero?
63
+ end
64
+
65
+ list.compact.reverse[0..increments - 1].join(' ')
66
+ end
67
+
68
+ # Filter Logic
69
+ # TODO: Simplify
70
+ # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
71
+ def self.filter(data, flags = {}, args = {})
72
+ # Experimenting with deep clone
73
+ # results = Marshal.load(Marshal.dump(data))
74
+ results = data.clone
75
+ # results = data
76
+
77
+ results.select! do |row|
78
+ args.send(flags.logic || :all?) do |arg|
79
+ filter_row_key(row, arg, flags)
80
+ end
81
+ end
82
+
83
+ # Ensure presecense of a specific field
84
+ results = filter_exists(results, flags[:exists]) if flags.key?(:exists)
85
+
86
+ # Time Zone
87
+ results = filter_modify_timezone(results, flags[:time_zone]) if flags.key?(:time_zone)
88
+
89
+ # Time Filtering
90
+ results = filter_time(results, flags) if flags.key?(:start) || flags.key?(:end)
91
+
92
+ # Strip Results if Slice is defined
93
+ results = filter_slice(results, flags[:slice]) if flags.key?(:slice)
94
+
95
+ # Strip Results if Except is defined
96
+ results = filter_except(results, flags[:except]) if flags.key?(:except)
97
+
98
+ # Remove Blank from either slice or except
99
+ results.reject!(&:empty?)
100
+
101
+ # Sort
102
+ results.sort_by! { |x| x.slice(*flags[:sort]).values } if flags.key?(:sort)
103
+
104
+ # JSON Formatting
105
+ results = results.map { |x| Oj.dump(x) } if flags.key?(:json)
106
+
107
+ # Show Unique Only
108
+ results = filter_uniq(results, flags[:uniq]) if flags.key?(:uniq)
109
+
110
+ # Reverse
111
+ results.reverse! if flags[:reverse]
112
+
113
+ # Count occurrences / Skip Results
114
+ return filter_stats(results, flags) if flags.key?(:stats)
115
+
116
+ # Limit before Pluck / Flattening
117
+ results = filter_limit(results, flags[:limit]) if flags.key?(:limit)
118
+
119
+ # Pluck
120
+ results = filter_pluck(results, flags[:pluck]) if flags.key?(:pluck)
121
+
122
+ results
123
+ end
124
+ # rubocop:enable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
125
+
126
+ # Limit / Ensure Exists and Valid Number
127
+ def self.filter_limit(results, limit)
128
+ return results unless limit.integer? && limit.positive?
129
+
130
+ results.shift limit
131
+ end
132
+
133
+ def self.filter_modify_timezone(results, time_zone)
134
+ results.map do |x|
135
+ next unless x.key? :time
136
+
137
+ x = x.clone # Prevent Top Level Modification
138
+ x[:time] = x[:time].in_time_zone time_zone
139
+
140
+ x
141
+ end
142
+ end
143
+
144
+ # Filter Start and End Times
145
+ # TODO: This is a bit icky, simplify/dry
146
+ def self.filter_time(results, flags)
147
+ if flags.key?(:start)
148
+ begin
149
+ time_start = Time.parse(flags[:start])
150
+
151
+ results.select! do |x|
152
+ if x.time
153
+ time_start < x.time
154
+ else
155
+ true
156
+ end
157
+ end
158
+ rescue StandardError
159
+ puts 'Unable to Process Start Time Filter'.pastel(:red)
160
+ end
161
+ end
162
+
163
+ if flags.key?(:end)
164
+ begin
165
+ time_start = Time.parse(flags[:end])
166
+
167
+ results.select! do |x|
168
+ if x.time
169
+ time_start > x.time
170
+ else
171
+ true
172
+ end
173
+ end
174
+ rescue StandardError
175
+ puts 'Unable to Process End Time Filter'.pastel(:red)
176
+ end
177
+ end
178
+
179
+ results
180
+ end
181
+
182
+ def self.filter_except(results, except)
183
+ # Avoid Empty Results
184
+ if except.empty?
185
+ filter_empty_arg('except')
186
+ return results
187
+ end
188
+
189
+ results.map { |row| row.except(*except) }
190
+ end
191
+
192
+ def self.filter_exists(results, exists)
193
+ # Avoid Empty Results
194
+ if exists.empty?
195
+ filter_empty_arg('exists')
196
+ return results
197
+ end
198
+
199
+ results.select { |row| (exists - row.keys).empty? }
200
+ end
201
+
202
+ def self.filter_slice(results, slice)
203
+ # Avoid Empty Results
204
+ if slice.empty?
205
+ filter_empty_arg('slice')
206
+ return results
207
+ end
208
+
209
+ results.compact.map { |row| row.slice(*slice) }
210
+ end
211
+
212
+ def self.filter_pluck(results, pluck)
213
+ # Avoid Empty Results
214
+ if pluck.empty?
215
+ filter_empty_arg('pluck')
216
+ return results
217
+ end
218
+
219
+ results.map { |x| x.slice(*pluck).values }.flatten
220
+ end
221
+
222
+ def self.filter_uniq(results, unique)
223
+ # Avoid Empty Results
224
+ if unique.empty?
225
+ filter_empty_arg('uniq')
226
+ return results
227
+ end
228
+
229
+ unique.map do |field|
230
+ results.uniq { |x| x[field] }
231
+ end.inject(:&)
232
+ end
233
+
234
+ def self.filter_stats(results, flags)
235
+ stats = flags[:stats]
236
+
237
+ # Avoid Empty Results
238
+ if stats.empty?
239
+ filter_empty_arg('stats')
240
+ return results
241
+ end
242
+
243
+ # Loop through Stats, Separate Hash/Tables
244
+ stats.map do |field|
245
+ occurrences = filter_count_occurrences(results, field, flags)
246
+
247
+ # Use Truncate For Long Keys
248
+ occurrences.transform_keys! { |key| key.to_s[0..flags[:truncate]] } if flags[:truncate]
249
+
250
+ # Total Occurences
251
+ total = occurrences.values.sum
252
+
253
+ # Percs
254
+ occurrences.transform_values! do |count|
255
+ [
256
+ count,
257
+ " #{percent(count, total)}%".pastel(:bright_black)
258
+ ]
259
+ end
260
+
261
+ # Sort by total occurances / New Variable for Total
262
+ output = occurrences.sort_by(&:last).to_h.transform_values!(&:join).to_a
263
+
264
+ # Append Header / Total with field name
265
+ output.unshift([field.to_s.pastel(:bright_black), total])
266
+
267
+ # Format
268
+ output.to_h
269
+ end
270
+ end
271
+
272
+ # Percent Helper
273
+ def self.percent(value, total)
274
+ ((value / total.to_f) * 100).round
275
+ end
276
+
277
+ # Helper to Count occurrences
278
+ def self.filter_count_occurrences(results, field, flags = {})
279
+ results.each_with_object(Hash.new(0)) do |entry, counts|
280
+ if entry.key? field
281
+ # Rounding in pagination breaks stats
282
+ key = if flags.key?(:round) && entry[field].numeric?
283
+ entry[field].to_f.round(flags.round)
284
+ else
285
+ entry[field]
286
+ end
287
+
288
+ counts[key] += 1
289
+ else
290
+ counts['None'.pastel(:bright_black)] += 1
291
+ end
292
+
293
+ counts
294
+ end
295
+ end
296
+
297
+ def self.filter_empty_arg(arg)
298
+ puts [
299
+ 'Ignoring'.pastel(:bright_yellow),
300
+ "--#{arg}".pastel(:cyan),
301
+ 'it requires an argument'.pastel(:red)
302
+ ].join(' ')
303
+ end
304
+
305
+ # Break out filter row logic into separate method
306
+ def self.filter_row_key(row, arg, flags)
307
+ return false if row.nil? # Nothing to filter if row empty
308
+
309
+ # Ignore Other Logic if Field isn't even included / Full Text Searching
310
+ return false unless row.key?(arg[:field]) || arg[:field] == :text
311
+
312
+ # Sensitivity Check / Check for Match / Full Text Searching
313
+ search_data = arg[:field] == :text ? row : row[arg.field]
314
+ match = filter_row_entry(search_data.to_s, arg, flags)
315
+
316
+ # Pivot of off include vs exclude
317
+ if arg.bang
318
+ !match
319
+ else
320
+ match
321
+ end
322
+ end
323
+
324
+ # Field Partial / Case / Exact search
325
+ def self.filter_row_entry(entry, arg, flags)
326
+ # Exact Matching / Unless doing full text search
327
+ return entry == arg.value.to_s if flags.key?(:exact) && arg.field != :text
328
+
329
+ # Cast to String/Integer Helper
330
+ entry, value = filter_entry_cast(entry, arg, flags)
331
+
332
+ entry.send(arg.logic, value)
333
+ end
334
+
335
+ # Handle casting to strings or integers
336
+ def self.filter_entry_cast(entry, arg, flags)
337
+ # Cast to String
338
+ value = arg.value.to_s
339
+
340
+ # No Logic on Empty Entries
341
+ return [entry, value] if entry.empty?
342
+
343
+ case arg.logic
344
+ when :include?
345
+
346
+ # Exact Case argument
347
+ unless flags.key?(:case)
348
+ entry = entry.downcase
349
+ value = value.downcase
350
+ end
351
+ when :>=, :<=
352
+ entry = entry.to_i if entry.numeric?
353
+ value = value.to_i if value&.numeric?
354
+ end
355
+
356
+ [entry, value]
357
+ end
358
+
359
+ def self.filter_entry_logic(entry, arg)
360
+ entry.send(arg.logic, arg.value)
361
+ end
362
+ end
363
+
364
+ # Internal Query Helper
365
+ # query = 'gitlab-rails/application_json.log --message!="Cannot obtain an exclusive lease" --severity=error'
366
+ # Query.filter_internal(query)
367
+ def self.filter_internal(search = '')
368
+ files, flags, args = Args.parse(Shellwords.split(search))
369
+ flags[:combine] = true
370
+
371
+ # Default to everything
372
+ files = Thing.all.map(&:name) if files.empty?
373
+
374
+ Query.start(files, flags, args)
375
+ end
376
+
377
+ # rubocop:enable Metrics/ModuleLength
378
+ end
@@ -12,7 +12,11 @@ module GreenHat
12
12
  Archive.all
13
13
  end
14
14
 
15
- ShellHelper.show(archives.map(&:report).map(&:show).flatten, flags)
15
+ output = archives.map { |x| x.report(flags) }.map(&:show).flatten
16
+
17
+ flags[:page] = true if flags.full && !flags.raw
18
+
19
+ ShellHelper.show(output, flags)
16
20
  end
17
21
  end
18
22
  end
@@ -23,15 +27,16 @@ module GreenHat
23
27
  class Report
24
28
  include ActionView::Helpers::NumberHelper
25
29
 
26
- attr_accessor :archive, :host, :os_release, :selinux_status, :cpu, :uname,
30
+ attr_accessor :archive, :flags, :host, :os_release, :selinux_status, :cpu, :uname,
27
31
  :timedatectl, :uptime, :meminfo, :gitlab_manifest, :gitlab_status,
28
- :production_log, :api_log, :application_log, :sidekiq_log,
32
+ :production_log, :api_log, :sidekiq_log,
29
33
  :exceptions_log, :gitaly_log, :free_m, :disk_free
30
34
 
31
35
  # Find Needed Files for Report
32
36
  # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity, Metrics/MethodLength
33
- def initialize(archive)
37
+ def initialize(archive, flags)
34
38
  self.archive = archive
39
+ self.flags = flags
35
40
  self.host = archive.things.find { |x| x.name == 'hostname' }
36
41
  self.os_release = archive.things.find { |x| x.name == 'etc/os-release' }
37
42
  self.selinux_status = archive.things.find { |x| x.name == 'sestatus' }
@@ -45,7 +50,6 @@ module GreenHat
45
50
  self.gitlab_status = archive.things.find { |x| x.name == 'gitlab_status' }
46
51
  self.production_log = archive.things.find { |x| x.name == 'gitlab-rails/production_json.log' }
47
52
  self.api_log = archive.things.find { |x| x.name == 'gitlab-rails/api_json.log' }
48
- self.application_log = archive.things.find { |x| x.name == 'gitlab-rails/application_json.log' }
49
53
  self.exceptions_log = archive.things.find { |x| x.name == 'gitlab-rails/exceptions_json.log' }
50
54
  self.gitaly_log = archive.things.find { |x| x.name == 'gitaly/current' }
51
55
  self.sidekiq_log = archive.things.find { |x| x.name == 'sidekiq/current' }
@@ -87,13 +91,16 @@ module GreenHat
87
91
  output << 'GitLab'.pastel(:bright_yellow) if gitlab_manifest
88
92
  output << gitlab_version if gitlab_manifest
89
93
  output << gitlab_services if gitlab_status
90
- output << title('Errors') if production_log || api_log || application_log || sidekiq_log
94
+ output << title('Errors') if production_log || api_log || sidekiq_log
91
95
  output << production_errors if production_log
92
- output << application_errors if application_log
96
+ output << application_errors if archive.thing?('gitlab-rails/application_json.log')
93
97
  output << sidekiq_errors if sidekiq_log
94
98
  output << api_errors if api_log
95
99
  output << exception_errors if exceptions_log
96
100
  output << gitaly_errors if gitaly_log
101
+ output << workhorse_errors if archive.thing?('gitlab-workhorse/current')
102
+
103
+ full(output) if flags.full
97
104
 
98
105
  # Final Space / Return
99
106
  output << ''
@@ -101,6 +108,17 @@ module GreenHat
101
108
  end
102
109
  # rubocop:enable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity, Metrics/MethodLength
103
110
 
111
+ def full(output)
112
+ output << ''
113
+ output << 'FastStats Top'
114
+ Shell::Faststats.top(['--raw'], true).each { |x| output << x } # Page Row Helper
115
+ output << ''
116
+
117
+ output << 'FastStats Errors'
118
+ Shell::Faststats.errors(['--raw'], true).each { |x| output << x } # Page Row Helper
119
+ output << ''
120
+ end
121
+
104
122
  def exception_errors
105
123
  count = exceptions_log.data.count
106
124
  color = count.zero? ? :green : :red
@@ -111,6 +129,21 @@ module GreenHat
111
129
  ].join
112
130
  end
113
131
 
132
+ def workhorse_errors
133
+ results = ShellHelper.filter_internal([
134
+ 'gitlab-workhorse/current',
135
+ '--level=error',
136
+ "--archive=#{archive.name}"
137
+ ].join(' '))
138
+
139
+ color = results.count.zero? ? :green : :red
140
+
141
+ [
142
+ title(' Workhorse', :bright_red, 18),
143
+ results.count.to_s.pastel(color)
144
+ ].join
145
+ end
146
+
114
147
  def gitaly_errors
115
148
  count = gitaly_log.data.count { |x| x.level == 'error' }
116
149
  color = count.zero? ? :green : :red
@@ -145,7 +178,8 @@ module GreenHat
145
178
  results = ShellHelper.filter_internal([
146
179
  'gitlab-rails/application_json.log',
147
180
  '--message!="Cannot obtain an exclusive lease"',
148
- '--severity=error'
181
+ '--severity=error',
182
+ "--archive=#{archive.name}"
149
183
  ].join(' '))
150
184
 
151
185
  count = results.count { |x| x&.severity == 'ERROR' }
@@ -200,6 +234,8 @@ module GreenHat
200
234
  end
201
235
 
202
236
  def selinux
237
+ return nil if selinux_status.data.nil?
238
+
203
239
  status = selinux_status.data['SELinux status']
204
240
  status_color = status == 'enabled' ? :green : :red
205
241
 
@@ -300,7 +336,7 @@ module GreenHat
300
336
  title('Usage'),
301
337
  ' ['.pastel(:bright_black),
302
338
  '='.pastel(:green) * (used / 2),
303
- ' ' * (50 - used / 2),
339
+ ' ' * (50 - (used / 2)),
304
340
  ']'.pastel(:bright_black),
305
341
  " #{100 - percent(free, total)}%".pastel(:green) # Inverse
306
342
  ].join
@@ -313,21 +349,37 @@ module GreenHat
313
349
 
314
350
  formatted_mem = free_m.data.map { |x| GreenHat::Memory.memory_row x }
315
351
 
316
- [
317
- title('Total', :cyan, 14),
318
- number_to_human_size(free.total.to_i * 1024**2),
319
- "\n",
320
- title('Used', :yellow, 14),
321
- number_to_human_size(free.used.to_i * 1024**2),
322
- "\n",
323
- title('Free', :blue, 14),
324
- number_to_human_size(free.free.to_i * 1024**2),
325
- "\n",
326
- title('Available', :green, 14),
327
- number_to_human_size(free.available.to_i * 1024**2),
328
- "\n\n",
329
- formatted_mem.map { |x| x.prepend ' ' * 2 }.join("\n")
330
- ].join
352
+ output = []
353
+ unless free.total.blank?
354
+ output << title('Total', :cyan, 14)
355
+ output << number_to_human_size(free.total.to_i * (1024**2))
356
+ output << "\n"
357
+ end
358
+
359
+ unless free.total.blank?
360
+ output << title('Used', :yellow, 14)
361
+ output << number_to_human_size(free.used.to_i * (1024**2))
362
+ output << "\n"
363
+ end
364
+
365
+ unless free.total.blank?
366
+ output << title('Free', :blue, 14)
367
+ output << number_to_human_size(free.free.to_i * (1024**2))
368
+ output << "\n"
369
+ end
370
+
371
+ unless free.total.blank?
372
+ output << title('Available', :green, 14)
373
+ output << number_to_human_size(free.available.to_i * (1024**2))
374
+ output << "\n"
375
+ end
376
+
377
+ output << "\n"
378
+ output << formatted_mem.map { |x| x.prepend ' ' * 2 }.join("\n")
379
+
380
+ output.join
381
+ rescue StandardError => e
382
+ LogBot.fatal('Memory', message: e.message, backtrace: e.backtrace.first)
331
383
  end
332
384
 
333
385
  def disks