morpheus-cli 5.2.0 → 5.2.4.1

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.
@@ -466,11 +466,16 @@ module Morpheus
466
466
 
467
467
  when :list
468
468
  opts.on( '-m', '--max MAX', "Max Results" ) do |val|
469
- max = val.to_i
470
- if max <= 0
471
- raise ::OptionParser::InvalidArgument.new("must be a positive integer")
469
+ # api supports max=-1 for all at the moment..
470
+ if val.to_s == "all" || val.to_s == "-1"
471
+ options[:max] = "-1"
472
+ else
473
+ max = val.to_i
474
+ if max <= 0
475
+ raise ::OptionParser::InvalidArgument.new("must be a positive integer")
476
+ end
477
+ options[:max] = max
472
478
  end
473
- options[:max] = max
474
479
  end
475
480
 
476
481
  opts.on( '-o', '--offset OFFSET', "Offset Results" ) do |val|
@@ -486,12 +491,17 @@ module Morpheus
486
491
  end
487
492
 
488
493
  opts.on( '-S', '--sort ORDER', "Sort Order. DIRECTION may be included as \"ORDER [asc|desc]\"." ) do |v|
489
- v_parts = v.to_s.split(" ")
490
- if v_parts.size > 1
491
- options[:sort] = v_parts[0]
492
- options[:direction] = (v_parts[1].strip == "desc") ? "desc" : "asc"
493
- else
494
+ if v.to_s.include?(",")
495
+ # sorting on multiple properties, just pass it as is, newer api supports multiple fields
494
496
  options[:sort] = v
497
+ else
498
+ v_parts = v.to_s.split(" ")
499
+ if v_parts.size > 1
500
+ options[:sort] = v_parts[0]
501
+ options[:direction] = (v_parts[1].strip == "desc") ? "desc" : "asc"
502
+ else
503
+ options[:sort] = v
504
+ end
495
505
  end
496
506
  end
497
507
 
@@ -26,8 +26,8 @@ class Morpheus::Cli::CurlCommand
26
26
  options = {}
27
27
  optparse = Morpheus::Cli::OptionParser.new do|opts|
28
28
  opts.banner = "Usage: morpheus curl [path] -- [*args]"
29
- opts.on( '-p', '--pretty', "Print result as parsed JSON." ) do
30
- options[:pretty] = true
29
+ opts.on( '-p', '--pretty', "Print result as parsed JSON. Alias for -j" ) do
30
+ options[:json] = true
31
31
  end
32
32
  opts.on( '-X', '--request METHOD', "HTTP request method. Default is GET" ) do |val|
33
33
  curl_method = val
@@ -134,29 +134,44 @@ EOT
134
134
  print reset
135
135
  return 0
136
136
  end
137
+ exit_code, err = 0, nil
137
138
  # print cyan
138
139
  # print "#{cyan}#{curl_cmd_str}#{reset}"
139
140
  # print "\n\n"
140
141
  print reset
141
142
  # print result
142
143
  curl_output = `#{curl_cmd}`
143
- if options[:pretty] || options[:json]
144
+
145
+ if $?.success? != true
146
+ exit_code = $?.exitstatus
147
+ err = "curl command exited non-zero"
148
+ end
149
+ json_response = {}
150
+ other_output = nil
151
+ if options[:json] || options[:yaml] || options[:csv]
144
152
  output_lines = curl_output.split("\n")
145
153
  last_line = output_lines.pop
146
154
  if output_lines.size > 0
147
- puts output_lines.join("\n")
155
+ other_output = output_lines.join("\n")
148
156
  end
149
157
  begin
150
- puts as_json(JSON.parse(last_line), options)
158
+ json_response = JSON.parse(last_line)
151
159
  rescue => ex
152
- Morpheus::Logging::DarkPrinter.puts "failed to parse curl result as JSON data Error: #{ex.message}" if Morpheus::Logging.debug?
153
- puts last_line
160
+ puts_error curl_output
161
+ print_red_alert "failed to parse curl result as JSON data Error: #{ex.message}"
162
+
163
+ exit_code = 2
164
+ err = "failed to parse curl result as JSON data Error: #{ex.message}"
165
+ return exit_code, err
154
166
  end
155
167
  else
156
- puts curl_output
168
+ other_output = curl_output
157
169
  end
158
- return $?.success?
159
-
170
+ curl_object_key = nil # json_response.keys.first
171
+ render_response(json_response, options, curl_object_key) do
172
+ puts other_output
173
+ end
174
+ return exit_code, err
160
175
  end
161
176
 
162
177
  def command_available?(cmd)
@@ -15,7 +15,7 @@ class Morpheus::Cli::HistoryCommand
15
15
  def handle(args)
16
16
  options = {show_pagination:false}
17
17
  optparse = Morpheus::Cli::OptionParser.new do|opts|
18
- opts.banner = "Usage: morpheus #{command_name}"
18
+ opts.banner = "Usage: morpheus #{command_name} [search]"
19
19
  # -n is a hidden alias for -m
20
20
  opts.on( '-n', '--max-commands MAX', "Alias for -m, --max option." ) do |val|
21
21
  options[:max] = val
@@ -35,6 +35,7 @@ The --flush option can be used to purge the history.
35
35
  Examples:
36
36
  history
37
37
  history -m 100
38
+ history "instances list"
38
39
  history --flush
39
40
 
40
41
  The most recently executed commands are seen by default. Use --reverse to see the oldest commands.
@@ -42,7 +43,10 @@ EOT
42
43
  end
43
44
  raw_cmd = "#{command_name} #{args.join(' ')}"
44
45
  optparse.parse!(args)
45
- verify_args!(args:args, count: 0, optparse:optparse)
46
+ # verify_args!(args:args, count: 0, optparse:optparse)
47
+ if args.count > 0
48
+ options[:phrase] = args.join(" ")
49
+ end
46
50
  if options[:do_flush]
47
51
  command_count = Morpheus::Cli::Shell.instance.history_commands_count
48
52
  unless options[:yes] || Morpheus::Cli::OptionTypes.confirm("Are you sure you want to flush your command history (#{format_number(command_count)} #{command_count == 1 ? 'command' : 'commands'})?")
@@ -4,8 +4,9 @@ require 'json'
4
4
 
5
5
  class Morpheus::Cli::DashboardCommand
6
6
  include Morpheus::Cli::CliCommand
7
+ include Morpheus::Cli::ProvisioningHelper
7
8
  set_command_name :dashboard
8
- set_command_hidden # remove once this is done
9
+ set_command_description "View Morpheus Dashboard"
9
10
 
10
11
  def initialize()
11
12
  # @appliance_name, @appliance_url = Morpheus::Cli::Remote.active_appliance
@@ -28,34 +29,273 @@ class Morpheus::Cli::DashboardCommand
28
29
  options = {}
29
30
  optparse = Morpheus::Cli::OptionParser.new do |opts|
30
31
  opts.banner = usage
31
- build_common_options(opts, options, [:json, :dry_run]) # todo: support :account
32
+ opts.on('-a', '--details', "Display all details: more instance usage stats, etc" ) do
33
+ options[:details] = true
34
+ end
35
+ build_standard_list_options(opts, options)
36
+ opts.footer = <<-EOT
37
+ View Morpheus Dashboard.
38
+ This includes instance and backup counts, favorite instances, monitoring and recent activity.
39
+ EOT
32
40
  end
33
41
  optparse.parse!(args)
34
-
42
+ verify_args!(args:args, optparse:optparse, count:0)
35
43
  connect(options)
36
- begin
37
- params = {}
38
- @dashboard_interface.setopts(options)
39
- if options[:dry_run]
40
- print_dry_run @dashboard_interface.dry.get(params)
41
- return
44
+ params = {}
45
+ params.merge!(parse_list_options(options))
46
+ @dashboard_interface.setopts(options)
47
+ if options[:dry_run]
48
+ print_dry_run @dashboard_interface.dry.get(params)
49
+ return
50
+ end
51
+ json_response = @dashboard_interface.get(params)
52
+ render_response(json_response, options) do
53
+ print_h1 "Morpheus Dashboard", [], options
54
+
55
+ ## STATUS
56
+
57
+ status_column_definitions = {
58
+ "Instances" => lambda {|it|
59
+ format_number(it['instanceStats']['total']) rescue nil
60
+ },
61
+ "Running" => lambda {|it|
62
+ format_number(it['instanceStats']['running']) rescue nil
63
+ },
64
+ # "Used Storage" => lambda {|it|
65
+ # ((it['instanceStats']['maxStorage'].to_i > 0) ? ((it['instanceStats']['usedStorage'].to_f / it['instanceStats']['maxStorage'].to_f) * 100).round(1) : 0).to_s + '%' rescue nil
66
+ # },
67
+ }
68
+ print as_description_list(json_response, status_column_definitions, options)
69
+ # print reset,"\n"
70
+
71
+ stats = json_response['instanceStats']
72
+ if stats
73
+ print_h2 "Instance Usage", options
74
+ print_stats_usage(stats, {include: [:max_cpu, :avg_cpu, :memory, :storage]})
42
75
  end
43
- json_response = @dashboard_interface.get(params)
44
- if options[:json]
45
- print JSON.pretty_generate(json_response)
46
- print "\n"
76
+
77
+
78
+
79
+ open_incident_count = json_response['monitoring']['openIncidents'] rescue (json_response['appStatus']['openIncidents'] rescue nil)
80
+
81
+ avg_response_time = json_response['monitoring']['avgResponseTime'] rescue nil
82
+ warning_apps = json_response['monitoring']['warningApps'] rescue 0
83
+ warning_checks = json_response['monitoring']['warningChecks'] rescue 0
84
+ fail_checks = json_response['monitoring']['failChecks'] rescue 0
85
+ fail_apps = json_response['monitoring']['failApps'] rescue 0
86
+ success_checks = json_response['monitoring']['successChecks'] rescue 0
87
+ success_apps = json_response['monitoring']['successApps'] rescue 0
88
+ monitoring_status_color = cyan
89
+ if fail_checks > 0 || fail_apps > 0
90
+ monitoring_status_color = red
91
+ elsif warning_checks > 0 || warning_apps > 0
92
+ monitoring_status_color = yellow
93
+ end
94
+
95
+ print_h2 "Monitoring"
96
+
97
+ monitoring_column_definitions = {
98
+ "Status" => lambda {|it|
99
+ if fail_apps > 0 # || fail_checks > 0
100
+ # check_summary = [fail_apps > 0 ? "#{fail_apps} Apps" : nil,fail_checks > 0 ? "#{fail_checks} Checks" : nil].compact.join(", ")
101
+ # red + "ERROR" + " (" + check_summary + ")" + cyan
102
+ red + "ERROR" + cyan
103
+ elsif warning_apps > 0 || warning_checks > 0
104
+ # check_summary = [warning_apps > 0 ? "#{warning_apps} Apps" : nil,warning_checks > 0 ? "#{warning_checks} Checks" : nil].compact.join(", ")
105
+ # red + "WARNING" + " (" + check_summary + ")" + cyan
106
+ yellow + "WARNING" + cyan
107
+ else
108
+ cyan + "HEALTHY" + cyan
109
+ end
110
+ },
111
+ # "Availability" => lambda {|it|
112
+ # # todo
113
+ # },
114
+ "Response Time" => lambda {|it|
115
+ # format_number(avg_response_time).to_s + "ms"
116
+ (avg_response_time.round).to_s + "ms"
117
+ },
118
+ "Open Incidents" => lambda {|it|
119
+ monitoring_status_color = cyan
120
+ # if fail_checks > 0 || fail_apps > 0
121
+ # monitoring_status_color = red
122
+ # elsif warning_checks > 0 || warning_apps > 0
123
+ # monitoring_status_color = yellow
124
+ # end
125
+ if open_incident_count.nil?
126
+ yellow + "n/a" + cyan + "\n"
127
+ elsif open_incident_count == 0
128
+ monitoring_status_color + "0 Open Incidents" + cyan
129
+ elsif open_incident_count == 1
130
+ monitoring_status_color + "1 Open Incident" + cyan
131
+ else
132
+ monitoring_status_color + "#{open_incident_count} Open Incidents" + cyan
133
+ end
134
+ }
135
+ }
136
+ #print as_description_list(json_response, monitoring_column_definitions, options)
137
+ print as_pretty_table([json_response], monitoring_column_definitions.upcase_keys!, options)
138
+
139
+
140
+ if json_response['logStats']
141
+ # todo: should come from monitoring.startMs-endMs
142
+ log_period_display = "7 Days"
143
+ print_h2 "Logs (#{log_period_display})", options
144
+ error_log_data = json_response['logStats']['data'].find {|it| it['key'].to_s.upcase == 'ERROR' }
145
+ error_count = error_log_data["count"] rescue 0
146
+ fatal_log_data = json_response['logStats']['data'].find {|it| it['key'].to_s.upcase == 'FATAL' }
147
+ fatal_count = fatal_log_data["count"] rescue 0
148
+ # total error is actaully error + fatal
149
+ total_error_count = error_count + fatal_count
150
+ # if total_error_count.nil?
151
+ # print yellow + "n/a" + cyan + "\n"
152
+ # elsif total_error_count == 0
153
+ # print cyan + "0 Errors" + cyan + "\n"
154
+ # elsif total_error_count == 1
155
+ # print red + "1 Error" + cyan + "\n"
156
+ # else
157
+ # print red + "#{total_error_count} Errors" + cyan + "\n"
158
+ # end
159
+ if total_error_count == 0
160
+ print cyan + "(0 Errors)" + cyan + "\n"
161
+ #print cyan + "0-0-0-0-0-0-0-0 (0 Errors)" + cyan + "\n"
162
+ end
163
+ if error_count > 0
164
+ if error_log_data["values"]
165
+ log_plot = ""
166
+ plot_index = 0
167
+ error_log_data["values"].each do |k, v|
168
+ if v.to_i == 0
169
+ log_plot << cyan + v.to_s
170
+ else
171
+ log_plot << red + v.to_s
172
+ end
173
+ if plot_index != error_log_data["values"].size - 1
174
+ log_plot << cyan + "-"
175
+ end
176
+ plot_index +=1
177
+ end
178
+ print log_plot
179
+ print " "
180
+ if error_count == 0
181
+ print cyan + "(0 Errors)" + cyan
182
+ elsif error_count == 1
183
+ print red + "(1 Errors)" + cyan
184
+ else
185
+ print red + "(#{error_count} Errors)" + cyan
186
+ end
187
+ print reset + "\n"
188
+ end
189
+ end
190
+ if fatal_count > 0
191
+ if fatal_log_data["values"]
192
+ log_plot = ""
193
+ plot_index = 0
194
+ fatal_log_data["values"].each do |k, v|
195
+ if v.to_i == 0
196
+ log_plot << cyan + v.to_s
197
+ else
198
+ log_plot << red + v.to_s
199
+ end
200
+ if plot_index != fatal_log_data["values"].size - 1
201
+ log_plot << cyan + "-"
202
+ end
203
+ plot_index +=1
204
+ end
205
+ print log_plot
206
+ print " "
207
+ if fatal_count == 0
208
+ print cyan + "(0 FATAL)" + cyan
209
+ elsif fatal_count == 1
210
+ print red + "(1 FATAL)" + cyan
211
+ else
212
+ print red + "(#{fatal_count} FATAL)" + cyan
213
+ end
214
+ print reset + "\n"
215
+ end
216
+ end
217
+ end
218
+
219
+ print_h2 "Backups (7 Days)"
220
+ backup_status_column_definitions = {
221
+ # "Total" => lambda {|it|
222
+ # it['backups']['accountStats']['lastSevenDays']['completed'] rescue nil
223
+ # },
224
+ "Successful" => lambda {|it|
225
+ it['backups']['accountStats']['lastSevenDays']['successful'] rescue nil
226
+ },
227
+ "Failed" => lambda {|it|
228
+ n = it['backups']['accountStats']['lastSevenDays']['failed'] rescue nil
229
+ if n == 0
230
+ cyan + n.to_s + reset
231
+ else
232
+ red + n.to_s + reset
233
+ end
234
+ }
235
+ }
236
+ print as_description_list(json_response, backup_status_column_definitions, options)
237
+ #print as_pretty_table([json_response], backup_status_column_definitions, options)
238
+ # print reset,"\n"
239
+
240
+ favorite_instances = json_response["provisioning"]["favoriteInstances"] || [] rescue []
241
+ if favorite_instances.empty?
242
+ # print cyan, "No favorite instances.",reset,"\n"
47
243
  else
244
+ print_h2 "My Instances"
245
+ favorite_instances_columns = {
246
+ "ID" => lambda {|instance|
247
+ instance['id']
248
+ },
249
+ "Name" => lambda {|instance|
250
+ instance['name']
251
+ },
252
+ "Type" => lambda {|instance|
253
+ instance['instanceType']['name'] rescue nil
254
+ },
255
+ "IP/PORT" => lambda {|instance|
256
+ format_instance_connection_string(instance)
257
+ },
258
+ "Status" => lambda {|it| format_instance_status(it) }
259
+ }
260
+ #print as_description_list(json_response, status_column_definitions, options)
261
+ print as_pretty_table(favorite_instances, favorite_instances_columns, options)
262
+ # print reset,"\n"
263
+ end
48
264
 
49
- print_h1 "Dashboard"
50
- print cyan
51
- puts "Coming soon... see --json"
52
- print reset,"\n"
265
+ # RECENT ACTIVITY
266
+ activity = json_response["activity"] || json_response["recentActivity"] || []
267
+ print_h2 "Recent Activity", [], options
268
+ if activity.empty?
269
+ print cyan, "No activity found.",reset,"\n"
270
+ else
271
+ columns = [
272
+ # {"SEVERITY" => lambda {|record| format_activity_severity(record['severity']) } },
273
+ {"TYPE" => lambda {|record| record['activityType'] } },
274
+ {"NAME" => lambda {|record| record['name'] } },
275
+ {"RESOURCE" => lambda {|record| "#{record['objectType']} #{record['objectId']}" } },
276
+ {"MESSAGE" => lambda {|record| record['message'] || '' } },
277
+ {"USER" => lambda {|record| record['user'] ? record['user']['username'] : record['userName'] } },
278
+ #{"DATE" => lambda {|record| "#{format_duration_ago(record['ts'] || record['timestamp'])}" } },
279
+ {"DATE" => lambda {|record|
280
+ # show full time if searching for custom timerange, otherwise the default is to show relative time
281
+ if params['start'] || params['end'] || params['timeframe']
282
+ "#{format_local_dt(record['ts'] || record['timestamp'])}"
283
+ else
284
+ "#{format_duration_ago(record['ts'] || record['timestamp'])}"
285
+ end
286
+
287
+ } },
288
+ ]
289
+ print as_pretty_table(activity, columns, options)
290
+ # print_results_pagination(json_response)
291
+ # print reset,"\n"
53
292
 
54
293
  end
55
- rescue RestClient::Exception => e
56
- print_rest_exception(e, options)
57
- exit 1
294
+
58
295
  end
296
+ print reset,"\n"
297
+ return 0, nil
59
298
  end
60
299
 
300
+
61
301
  end
@@ -117,6 +117,12 @@ class Morpheus::Cli::Hosts
117
117
  opts.on( '--tenant TENANT', "Tenant Name or ID" ) do |val|
118
118
  options[:account] = val
119
119
  end
120
+ opts.on('--labels label',String, "Filter by labels (keywords).") do |val|
121
+ val.split(",").each do |k|
122
+ options[:labels] ||= []
123
+ options[:labels] << k.strip
124
+ end
125
+ end
120
126
  opts.on('--tags Name=Value',String, "Filter by tags.") do |val|
121
127
  val.split(",").each do |value_pair|
122
128
  k,v = value_pair.strip.split("=")
@@ -186,6 +192,7 @@ class Morpheus::Cli::Hosts
186
192
  params['clusterId'] = cluster['id']
187
193
  end
188
194
  end
195
+ params['labels'] = options[:labels] if options[:labels]
189
196
  if options[:tags] && !options[:tags].empty?
190
197
  options[:tags].each do |k,v|
191
198
  params['tags.' + k] = v
@@ -530,9 +537,10 @@ class Morpheus::Cli::Hosts
530
537
  puts records_as_csv([json_response['server']], options)
531
538
  return 0
532
539
  end
533
- server = json_response['server']
540
+ server = json_response['server'] || json_response['host'] || {}
534
541
  #stats = server['stats'] || json_response['stats'] || {}
535
542
  stats = json_response['stats'] || {}
543
+ tags = server['tags'] || server['metadata']
536
544
  title = "Host Details"
537
545
  print_h1 title, [], options
538
546
  print cyan
@@ -541,6 +549,8 @@ class Morpheus::Cli::Hosts
541
549
  "Name" => 'name',
542
550
  "Hostname" => 'hostname',
543
551
  "Description" => 'description',
552
+ "Labels" => lambda {|it| it['labels'] ? it['labels'].join(',') : '' },
553
+ "Tags" => lambda {|it| tags ? format_metadata(tags) : '' },
544
554
  "Owner" => lambda {|it| it['owner'] ? it['owner']['username'] : '' },
545
555
  "Tenant" => lambda {|it| it['account'] ? it['account']['name'] : '' },
546
556
  #"Group" => lambda {|it| it['group'] ? it['group']['name'] : '' },
@@ -564,6 +574,8 @@ class Morpheus::Cli::Hosts
564
574
  # server_columns.delete("Tenant") if multi_tenant != true
565
575
  server_columns.delete("Cost") if server['hourlyCost'].to_f == 0
566
576
  server_columns.delete("Price") if server['hourlyPrice'].to_f == 0 || server['hourlyPrice'] == server['hourlyCost']
577
+ server_columns.delete("Labels") if server['labels'].nil? || server['labels'].empty?
578
+ server_columns.delete("Tags") if tags.nil? || tags.empty?
567
579
 
568
580
  print_description_list(server_columns, server)
569
581
 
@@ -990,6 +1002,22 @@ class Morpheus::Cli::Hosts
990
1002
  opts.on('--power-schedule-type ID', String, "Power Schedule Type ID") do |val|
991
1003
  params['powerScheduleType'] = val == "null" ? nil : val
992
1004
  end
1005
+ opts.on('--labels [LIST]', String, "Labels (keywords) in the format 'foo, bar'") do |val|
1006
+ params['labels'] = val.to_s.split(',').collect {|it| it.to_s.strip }.compact.uniq.join(',')
1007
+ end
1008
+ opts.on('--tags LIST', String, "Tags in the format 'name:value, name:value'. This will add and remove tags.") do |val|
1009
+ options[:tags] = val
1010
+ end
1011
+ opts.on('--metadata LIST', String, "Alias for --tags.") do |val|
1012
+ options[:tags] = val
1013
+ end
1014
+ opts.add_hidden_option('--metadata')
1015
+ opts.on('--add-tags TAGS', String, "Add Tags in the format 'name:value, name:value'. This will only add/update tags.") do |val|
1016
+ options[:add_tags] = val
1017
+ end
1018
+ opts.on('--remove-tags TAGS', String, "Remove Tags in the format 'name, name:value'. This removes tags, the :value component is optional and must match if passed.") do |val|
1019
+ options[:remove_tags] = val
1020
+ end
993
1021
  # opts.on('--created-by ID', String, "Created By User ID") do |val|
994
1022
  # params['createdById'] = val
995
1023
  # end
@@ -1008,6 +1036,18 @@ class Morpheus::Cli::Hosts
1008
1036
  new_group = nil
1009
1037
  passed_options = options[:options] ? options[:options].reject {|k,v| k.is_a?(Symbol) } : {}
1010
1038
  params.deep_merge!(passed_options) unless passed_options.empty?
1039
+ # metadata tags
1040
+ if options[:tags]
1041
+ params['tags'] = parse_metadata(options[:tags])
1042
+ else
1043
+ # params['tags'] = prompt_metadata(options)
1044
+ end
1045
+ if options[:add_tags]
1046
+ params['addTags'] = parse_metadata(options[:add_tags])
1047
+ end
1048
+ if options[:remove_tags]
1049
+ params['removeTags'] = parse_metadata(options[:remove_tags])
1050
+ end
1011
1051
  payload = nil
1012
1052
  if options[:payload]
1013
1053
  payload = options[:payload]
@@ -1919,7 +1959,8 @@ class Morpheus::Cli::Hosts
1919
1959
  snapshot_column_definitions = {
1920
1960
  "ID" => lambda {|it| it['id'] },
1921
1961
  "Name" => lambda {|it| it['name'] },
1922
- "Description" => lambda {|it| it['snapshotType'] ? (it['snapshotType']['name'] || it['snapshotType']['code']) : '' },
1962
+ "Description" => lambda {|it| it['description'] },
1963
+ # "Type" => lambda {|it| it['snapshotType'] },
1923
1964
  "Date Created" => lambda {|it| format_local_dt(it['snapshotCreated']) },
1924
1965
  "Status" => lambda {|it| format_snapshot_status(it) }
1925
1966
  }