wavefront-client 3.5.4 → 3.6.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.
- checksums.yaml +4 -4
- data/README-cli.md +162 -9
- data/bin/wavefront +41 -1
- data/lib/wavefront/alerting.rb +111 -24
- data/lib/wavefront/cli/alerts.rb +61 -7
- data/lib/wavefront/cli/dashboards.rb +138 -0
- data/lib/wavefront/client/version.rb +1 -1
- data/lib/wavefront/constants.rb +5 -2
- data/lib/wavefront/dashboards.rb +106 -0
- data/lib/wavefront/metadata.rb +0 -34
- data/lib/wavefront/mixins.rb +57 -8
- data/spec/cli_spec.rb +335 -9
- data/spec/spec_helper.rb +7 -0
- data/spec/wavefront/alerting_spec.rb +62 -15
- data/spec/wavefront/cli/alerts_spec.rb +2 -2
- data/spec/wavefront/cli/resources/alert.human.erb +2 -2
- data/spec/wavefront/cli/resources/alert.human2 +2 -2
- data/spec/wavefront/{resources → cli/resources}/conf.yaml +0 -0
- data/spec/wavefront/cli/resources/input_alert.json +34 -0
- data/spec/wavefront/cli/resources/input_alert.yaml +22 -0
- data/spec/wavefront/cli/resources/sample_alert.json +63 -0
- data/spec/wavefront/cli/resources/sample_alert.txt +2 -0
- data/spec/wavefront/cli/resources/sample_alert.yaml +47 -0
- data/spec/wavefront/cli/resources/sample_dash.json +1 -0
- data/spec/wavefront/cli/resources/sample_dash.txt +2 -0
- data/spec/wavefront/cli/resources/sample_dash.yaml +1114 -0
- data/spec/wavefront/opt_handler_spec.rb +3 -5
- metadata +22 -4
data/lib/wavefront/cli/alerts.rb
CHANGED
@@ -1,5 +1,3 @@
|
|
1
|
-
#!/usr/bin/env ruby
|
2
|
-
|
3
1
|
# Copyright 2015 Wavefront Inc.
|
4
2
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
5
3
|
# you may not use this file except in compliance with the License.
|
@@ -15,24 +13,38 @@
|
|
15
13
|
|
16
14
|
require 'wavefront/alerting'
|
17
15
|
require 'wavefront/cli'
|
16
|
+
require 'wavefront/mixins'
|
18
17
|
require 'json'
|
18
|
+
require 'yaml'
|
19
19
|
require 'pp'
|
20
20
|
require 'time'
|
21
21
|
|
22
22
|
class Wavefront::Cli::Alerts < Wavefront::Cli
|
23
|
+
include Wavefront::Mixins
|
23
24
|
|
24
|
-
attr_accessor :options, :arguments
|
25
|
+
attr_accessor :options, :arguments, :wfa
|
25
26
|
|
26
27
|
def run
|
27
28
|
raise 'Missing token.' if ! @options[:token] || @options[:token].empty?
|
28
29
|
raise 'Missing query.' if arguments.empty?
|
29
|
-
|
30
|
+
valid_format?(@options[:alertformat].to_sym)
|
30
31
|
|
31
|
-
wfa = Wavefront::Alerting.new(@options[:token], @options[:endpoint],
|
32
|
+
@wfa = Wavefront::Alerting.new(@options[:token], @options[:endpoint],
|
32
33
|
@options[:debug], {
|
33
34
|
noop: @options[:noop], verbose: @options[:verbose]})
|
35
|
+
|
36
|
+
if options[:export]
|
37
|
+
export_alert(options[:'<timestamp>'])
|
38
|
+
return
|
39
|
+
end
|
40
|
+
|
41
|
+
if options[:import]
|
42
|
+
import_alert
|
43
|
+
return
|
44
|
+
end
|
45
|
+
|
46
|
+
query = arguments[0].to_sym
|
34
47
|
valid_state?(wfa, query)
|
35
|
-
valid_format?(@options[:alertformat].to_sym)
|
36
48
|
options = { host: @options[:endpoint] }
|
37
49
|
|
38
50
|
if @options[:shared]
|
@@ -54,6 +66,46 @@ class Wavefront::Cli::Alerts < Wavefront::Cli
|
|
54
66
|
exit
|
55
67
|
end
|
56
68
|
|
69
|
+
def import_alert
|
70
|
+
raw = load_file(options[:'<file>'])
|
71
|
+
|
72
|
+
begin
|
73
|
+
prepped = wfa.import_to_create(raw)
|
74
|
+
rescue => e
|
75
|
+
puts e if options[:debug]
|
76
|
+
raise 'could not parse input.'
|
77
|
+
end
|
78
|
+
|
79
|
+
begin
|
80
|
+
wfa.create_alert(prepped)
|
81
|
+
puts 'Alert imported.' unless options[:noop]
|
82
|
+
rescue RestClient::BadRequest
|
83
|
+
raise '400 error: alert probably exists.'
|
84
|
+
end
|
85
|
+
end
|
86
|
+
|
87
|
+
def export_alert(id)
|
88
|
+
begin
|
89
|
+
resp = wfa.get_alert(id)
|
90
|
+
rescue => e
|
91
|
+
puts e if @options[:debug]
|
92
|
+
raise 'Unable to retrieve alert.'
|
93
|
+
end
|
94
|
+
|
95
|
+
return if options[:noop]
|
96
|
+
|
97
|
+
case options[:alertformat].to_sym
|
98
|
+
when :json
|
99
|
+
puts JSON.pretty_generate(resp)
|
100
|
+
when :yaml
|
101
|
+
puts resp.to_yaml
|
102
|
+
when :human
|
103
|
+
puts humanize([resp])
|
104
|
+
else
|
105
|
+
puts 'unknown output format.'
|
106
|
+
end
|
107
|
+
end
|
108
|
+
|
57
109
|
def format_result(result, format)
|
58
110
|
#
|
59
111
|
# Call a suitable method to display the output of the API call,
|
@@ -66,6 +118,8 @@ class Wavefront::Cli::Alerts < Wavefront::Cli
|
|
66
118
|
pp result
|
67
119
|
when :json
|
68
120
|
puts JSON.pretty_generate(JSON.parse(result))
|
121
|
+
when :yaml
|
122
|
+
puts JSON.parse(result).to_yaml
|
69
123
|
when :human
|
70
124
|
puts humanize(JSON.parse(result))
|
71
125
|
else
|
@@ -137,7 +191,7 @@ class Wavefront::Cli::Alerts < Wavefront::Cli
|
|
137
191
|
# The 'created' and 'updated' timestamps are in epoch
|
138
192
|
# milliseconds
|
139
193
|
#
|
140
|
-
human_line(k, Time.at(v / 1000))
|
194
|
+
human_line(k, "#{Time.at(v / 1000)} (#{v})")
|
141
195
|
end
|
142
196
|
|
143
197
|
def human_line_updated(k, v)
|
@@ -0,0 +1,138 @@
|
|
1
|
+
require 'wavefront/dashboards'
|
2
|
+
require 'wavefront/cli'
|
3
|
+
require 'pathname'
|
4
|
+
require 'json'
|
5
|
+
require 'yaml'
|
6
|
+
|
7
|
+
class Wavefront::Cli::Dashboards < Wavefront::Cli
|
8
|
+
attr_accessor :wfd
|
9
|
+
|
10
|
+
include Wavefront::Constants
|
11
|
+
include Wavefront::Mixins
|
12
|
+
|
13
|
+
def run
|
14
|
+
@wfd = Wavefront::Dashboards.new(
|
15
|
+
options[:token], options[:endpoint], options[:debug],
|
16
|
+
noop: options[:noop], verbose: options[:verbose]
|
17
|
+
)
|
18
|
+
|
19
|
+
list_dashboards if options[:list]
|
20
|
+
export_dash if options[:export]
|
21
|
+
create_dash if options[:create]
|
22
|
+
delete_dash if options[:delete]
|
23
|
+
undelete_dash if options[:undelete]
|
24
|
+
history_dash if options[:history]
|
25
|
+
clone_dash if options[:clone]
|
26
|
+
import_dash if options[:import]
|
27
|
+
end
|
28
|
+
|
29
|
+
def import_dash
|
30
|
+
wfd.import(load_file(options[:'<file>']).to_json, options[:force])
|
31
|
+
puts 'Dashboard imported' unless options[:noop]
|
32
|
+
rescue RestClient::BadRequest
|
33
|
+
raise '400 error: dashboard probably exists, and force not used'
|
34
|
+
end
|
35
|
+
|
36
|
+
def clone_dash
|
37
|
+
wfd.clone(options[:'<source_id>'], options[:'<new_id>'],
|
38
|
+
options[:'<new_name>'], options[:version])
|
39
|
+
puts 'Dashboard cloned' unless options[:noop]
|
40
|
+
rescue RestClient::BadRequest
|
41
|
+
raise '400 error: either target exists or source does not'
|
42
|
+
end
|
43
|
+
|
44
|
+
def history_dash
|
45
|
+
begin
|
46
|
+
resp = wfd.history(options[:'<dashboard_id>'],
|
47
|
+
options[:start] || 100,
|
48
|
+
options[:limit] || nil)
|
49
|
+
rescue RestClient::ResourceNotFound
|
50
|
+
raise 'Dashboard does not exist'
|
51
|
+
end
|
52
|
+
|
53
|
+
display_resp(resp, :human_history)
|
54
|
+
end
|
55
|
+
|
56
|
+
def undelete_dash
|
57
|
+
wfd.undelete(options[:'<dashboard_id>'])
|
58
|
+
puts 'dashboard undeleted' unless options[:noop]
|
59
|
+
rescue RestClient::ResourceNotFound
|
60
|
+
raise 'Dashboard does not exist'
|
61
|
+
end
|
62
|
+
|
63
|
+
def delete_dash
|
64
|
+
wfd.delete(options[:'<dashboard_id>'])
|
65
|
+
puts 'dashboard deleted' unless options[:noop]
|
66
|
+
rescue RestClient::ResourceNotFound
|
67
|
+
raise 'Dashboard does not exist'
|
68
|
+
end
|
69
|
+
|
70
|
+
def create_dash
|
71
|
+
wfd.create(options[:'<dashboard_id>'], options[:'<name>'])
|
72
|
+
puts 'dashboard created' unless options[:noop]
|
73
|
+
rescue RestClient::BadRequest
|
74
|
+
raise '400 error: dashboard probably exists'
|
75
|
+
end
|
76
|
+
|
77
|
+
def export_dash
|
78
|
+
resp = wfd.export(options[:'<dashboard_id>'], options[:version] || nil)
|
79
|
+
options[:dashformat] = :json if options[:dashformat] == :human
|
80
|
+
display_resp(resp)
|
81
|
+
end
|
82
|
+
|
83
|
+
def list_dashboards
|
84
|
+
resp = wfd.list({ private: options[:privatetag],
|
85
|
+
shared: options[:sharedtag] })
|
86
|
+
display_resp(resp, :human_list)
|
87
|
+
end
|
88
|
+
|
89
|
+
def display_resp(resp, human_method = nil)
|
90
|
+
return if options[:noop]
|
91
|
+
|
92
|
+
case options[:dashformat].to_sym
|
93
|
+
when :json
|
94
|
+
if resp.is_a?(String)
|
95
|
+
puts resp
|
96
|
+
else
|
97
|
+
puts resp.to_json
|
98
|
+
end
|
99
|
+
when :yaml
|
100
|
+
puts resp.to_yaml
|
101
|
+
when :human
|
102
|
+
unless human_method
|
103
|
+
raise 'human output format is not supported by this subcommand'
|
104
|
+
end
|
105
|
+
|
106
|
+
send(human_method, JSON.parse(resp))
|
107
|
+
else
|
108
|
+
raise 'unsupported output format'
|
109
|
+
end
|
110
|
+
end
|
111
|
+
|
112
|
+
def human_history(resp)
|
113
|
+
resp.each do |rev|
|
114
|
+
puts format('%-4s%s (%s)', rev['version'],
|
115
|
+
Time.at(rev['update_time'].to_i / 1000),
|
116
|
+
rev['update_user'])
|
117
|
+
|
118
|
+
next unless rev['change_description']
|
119
|
+
rev['change_description'].each { |desc| puts ' ' + desc }
|
120
|
+
end
|
121
|
+
end
|
122
|
+
|
123
|
+
def human_list(resp)
|
124
|
+
#
|
125
|
+
# Simply list the dashboards we have. If the user wants more
|
126
|
+
#
|
127
|
+
max_id_width = resp.map { |s| s['url'].size }.max
|
128
|
+
|
129
|
+
puts format("%-#{max_id_width + 1}s%s", 'ID', 'NAME')
|
130
|
+
|
131
|
+
resp.each do |dash|
|
132
|
+
next if !options[:all] && dash['isTrash']
|
133
|
+
line = format("%-#{max_id_width + 1}s%s", dash['url'], dash['name'])
|
134
|
+
line.<< ' (in trash)' if dash['isTrash']
|
135
|
+
puts line
|
136
|
+
end
|
137
|
+
end
|
138
|
+
end
|
data/lib/wavefront/constants.rb
CHANGED
@@ -26,10 +26,12 @@ module Wavefront
|
|
26
26
|
DEFAULT_STRICT = true
|
27
27
|
DEFAULT_OBSOLETE_METRICS = false
|
28
28
|
FORMATS = [ :raw, :ruby, :graphite, :highcharts, :human ]
|
29
|
-
ALERT_FORMATS = [:ruby, :json, :human]
|
29
|
+
ALERT_FORMATS = [:ruby, :json, :human, :yaml]
|
30
30
|
SOURCE_FORMATS = [:ruby, :json, :human]
|
31
|
+
DASH_FORMATS = [:json, :human, :yaml]
|
31
32
|
DEFAULT_ALERT_FORMAT = :human
|
32
33
|
DEFAULT_SOURCE_FORMAT = :human
|
34
|
+
DEFAULT_DASH_FORMAT = :human
|
33
35
|
GRANULARITIES = %w( s m h d )
|
34
36
|
EVENT_STATE_DIR = Pathname.new('/var/tmp/wavefront/events')
|
35
37
|
EVENT_LEVELS = %w(info smoke warn severe)
|
@@ -51,7 +53,8 @@ module Wavefront
|
|
51
53
|
format: DEFAULT_FORMAT, # ts output format
|
52
54
|
alertformat: DEFAULT_ALERT_FORMAT, # alert command output format
|
53
55
|
infileformat: DEFAULT_INFILE_FORMAT, # batch writer file format
|
54
|
-
sourceformat: DEFAULT_SOURCE_FORMAT, # source
|
56
|
+
sourceformat: DEFAULT_SOURCE_FORMAT, # source output format
|
57
|
+
dashformat: DEFAULT_DASH_FORMAT, # dashboard output format
|
55
58
|
}.freeze
|
56
59
|
end
|
57
60
|
end
|
@@ -0,0 +1,106 @@
|
|
1
|
+
require_relative 'client/version'
|
2
|
+
require_relative 'exception'
|
3
|
+
require 'rest_client'
|
4
|
+
require 'uri'
|
5
|
+
require 'logger'
|
6
|
+
require 'wavefront/constants'
|
7
|
+
require 'wavefront/mixins'
|
8
|
+
|
9
|
+
module Wavefront
|
10
|
+
#
|
11
|
+
# Wrappers around the v1 dashboards API
|
12
|
+
#
|
13
|
+
class Dashboards
|
14
|
+
include Wavefront::Constants
|
15
|
+
include Wavefront::Mixins
|
16
|
+
DEFAULT_PATH = '/api/dashboard/'.freeze
|
17
|
+
|
18
|
+
attr_reader :headers, :noop, :verbose, :endpoint
|
19
|
+
|
20
|
+
def initialize(token, host = DEFAULT_HOST, debug = false, options = {})
|
21
|
+
#
|
22
|
+
# Following existing convention, 'host' is the Wavefront API endpoint.
|
23
|
+
#
|
24
|
+
@headers = { :'X-AUTH-TOKEN' => token }
|
25
|
+
@endpoint = host
|
26
|
+
debug(debug)
|
27
|
+
@noop = options[:noop]
|
28
|
+
@verbose = options[:verbose]
|
29
|
+
end
|
30
|
+
|
31
|
+
def import(schema, force = false)
|
32
|
+
#
|
33
|
+
# Imports a dashboard described as a JSON string (schema)
|
34
|
+
#
|
35
|
+
qs = force ? nil : 'rejectIfExists=true'
|
36
|
+
call_post(create_uri(qs: qs), schema, 'application/json')
|
37
|
+
end
|
38
|
+
|
39
|
+
def clone(source_id, dest_id, dest_name, source_ver = nil)
|
40
|
+
#
|
41
|
+
# Clone a dashboard. If source_ver is not truthy, the latest
|
42
|
+
# version of the source is used.
|
43
|
+
#
|
44
|
+
qs = hash_to_qs(name: dest_name, url: dest_id)
|
45
|
+
qs.<< "&v=#{source_ver}" if source_ver
|
46
|
+
|
47
|
+
call_post(create_uri(path: uri_concat(source_id, 'clone')), qs,
|
48
|
+
'application/x-www-form-urlencoded')
|
49
|
+
end
|
50
|
+
|
51
|
+
def history(id, start = 100, limit = nil)
|
52
|
+
qs = "start=#{start}"
|
53
|
+
qs.<< "&limit=#{limit}" if limit
|
54
|
+
|
55
|
+
call_get(create_uri(path: uri_concat(id, 'history'), qs: qs))
|
56
|
+
end
|
57
|
+
|
58
|
+
def list(opts = {})
|
59
|
+
qs = []
|
60
|
+
|
61
|
+
opts[:private].map { |t| qs.<< "userTag=#{t}" } if opts[:private]
|
62
|
+
opts[:shared].map { |t| qs.<< "customerTag=#{t}" } if opts[:shared]
|
63
|
+
|
64
|
+
call_get(create_uri(qs: qs.join('&')))
|
65
|
+
end
|
66
|
+
|
67
|
+
def undelete(id)
|
68
|
+
call_post(create_uri(path: uri_concat(id, 'undelete')))
|
69
|
+
end
|
70
|
+
|
71
|
+
def delete(id)
|
72
|
+
call_post(create_uri(path: uri_concat(id, 'delete')))
|
73
|
+
end
|
74
|
+
|
75
|
+
def export(id, version = nil)
|
76
|
+
path = version ? uri_concat(id, version) : id
|
77
|
+
resp = call_get(create_uri(path: path)) || '{}'
|
78
|
+
JSON.parse(resp)
|
79
|
+
end
|
80
|
+
|
81
|
+
def create(id, name)
|
82
|
+
call_post(create_uri(path: uri_concat([id, 'create'])),
|
83
|
+
"name=#{URI.encode(name)}",
|
84
|
+
'application/x-www-form-urlencoded')
|
85
|
+
end
|
86
|
+
|
87
|
+
def create_uri(options = {})
|
88
|
+
#
|
89
|
+
# Build the URI we use to send a 'create' request.
|
90
|
+
#
|
91
|
+
options[:host] ||= endpoint
|
92
|
+
options[:path] ||= ''
|
93
|
+
options[:qs] ||= nil
|
94
|
+
|
95
|
+
URI::HTTPS.build(
|
96
|
+
host: options[:host],
|
97
|
+
path: uri_concat(DEFAULT_PATH, options[:path]),
|
98
|
+
query: options[:qs]
|
99
|
+
)
|
100
|
+
end
|
101
|
+
|
102
|
+
def debug(enabled)
|
103
|
+
RestClient.log = 'stdout' if enabled
|
104
|
+
end
|
105
|
+
end
|
106
|
+
end
|
data/lib/wavefront/metadata.rb
CHANGED
@@ -209,40 +209,6 @@ module Wavefront
|
|
209
209
|
)
|
210
210
|
end
|
211
211
|
|
212
|
-
def call_get(uri)
|
213
|
-
if (verbose || noop)
|
214
|
-
puts 'GET ' + uri.to_s
|
215
|
-
puts 'HEADERS ' + headers.to_s
|
216
|
-
end
|
217
|
-
return if noop
|
218
|
-
RestClient.get(uri.to_s, headers)
|
219
|
-
end
|
220
|
-
|
221
|
-
def call_delete(uri)
|
222
|
-
if (verbose || noop)
|
223
|
-
puts 'DELETE ' + uri.to_s
|
224
|
-
puts 'HEADERS ' + headers.to_s
|
225
|
-
end
|
226
|
-
return if noop
|
227
|
-
RestClient.delete(uri.to_s, headers)
|
228
|
-
end
|
229
|
-
|
230
|
-
def call_post(uri, query = nil)
|
231
|
-
h = headers
|
232
|
-
if (verbose || noop)
|
233
|
-
puts 'POST ' + uri.to_s
|
234
|
-
puts 'QUERY ' + query if query
|
235
|
-
puts 'HEADERS ' + h.to_s
|
236
|
-
end
|
237
|
-
return if noop
|
238
|
-
|
239
|
-
RestClient.post(uri.to_s, query,
|
240
|
-
h.merge(:'Content-Type' => 'text/plain',
|
241
|
-
:Accept => 'application/json'
|
242
|
-
)
|
243
|
-
)
|
244
|
-
end
|
245
|
-
|
246
212
|
def debug(enabled)
|
247
213
|
RestClient.log = 'stdout' if enabled
|
248
214
|
end
|
data/lib/wavefront/mixins.rb
CHANGED
@@ -33,14 +33,12 @@ module Wavefront
|
|
33
33
|
#
|
34
34
|
# Return a time as an integer, however it might come in.
|
35
35
|
#
|
36
|
-
|
37
|
-
|
38
|
-
|
39
|
-
|
40
|
-
|
41
|
-
|
42
|
-
raise "cannot parse timestamp '#{t}'."
|
43
|
-
end
|
36
|
+
return t if t.is_a?(Integer)
|
37
|
+
return t.to_i if t.is_a?(Time)
|
38
|
+
return t.to_i if t.is_a?(String) && t.match(/^\d+$/)
|
39
|
+
DateTime.parse("#{t} #{Time.now.getlocal.zone}").to_time.utc.to_i
|
40
|
+
rescue
|
41
|
+
raise "cannot parse timestamp '#{t}'."
|
44
42
|
end
|
45
43
|
|
46
44
|
def time_to_ms(t)
|
@@ -62,5 +60,56 @@ module Wavefront
|
|
62
60
|
def uri_concat(*args)
|
63
61
|
args.join('/').squeeze('/')
|
64
62
|
end
|
63
|
+
|
64
|
+
def call_get(uri)
|
65
|
+
if verbose || noop
|
66
|
+
puts 'GET ' + uri.to_s
|
67
|
+
puts 'HEADERS ' + headers.to_s
|
68
|
+
end
|
69
|
+
return if noop
|
70
|
+
RestClient.get(uri.to_s, headers)
|
71
|
+
end
|
72
|
+
|
73
|
+
def call_post(uri, query = nil, ctype = 'text/plain')
|
74
|
+
h = headers
|
75
|
+
if verbose || noop
|
76
|
+
puts 'POST ' + uri.to_s
|
77
|
+
puts 'QUERY ' + query if query
|
78
|
+
puts 'HEADERS ' + h.to_s
|
79
|
+
end
|
80
|
+
return if noop
|
81
|
+
|
82
|
+
RestClient.post(uri.to_s, query,
|
83
|
+
h.merge(:'Content-Type' => ctype,
|
84
|
+
:Accept => 'application/json'))
|
85
|
+
end
|
86
|
+
|
87
|
+
def call_delete(uri)
|
88
|
+
if verbose || noop
|
89
|
+
puts 'DELETE ' + uri.to_s
|
90
|
+
puts 'HEADERS ' + headers.to_s
|
91
|
+
end
|
92
|
+
return if noop
|
93
|
+
RestClient.delete(uri.to_s, headers)
|
94
|
+
end
|
95
|
+
|
96
|
+
def load_file(path)
|
97
|
+
#
|
98
|
+
# Give it a path to a file (as a string) and it will return the
|
99
|
+
# contents of that file as a Ruby object. Automatically detects
|
100
|
+
# JSON and YAML. Raises an exception if it doesn't look like
|
101
|
+
# either.
|
102
|
+
#
|
103
|
+
file = Pathname.new(path)
|
104
|
+
raise 'Import file does not exist.' unless file.exist?
|
105
|
+
|
106
|
+
if file.extname == '.json'
|
107
|
+
JSON.parse(IO.read(file))
|
108
|
+
elsif file.extname == '.yaml' || file.extname == '.yml'
|
109
|
+
YAML.load(IO.read(file))
|
110
|
+
else
|
111
|
+
raise 'Unsupported file format.'
|
112
|
+
end
|
113
|
+
end
|
65
114
|
end
|
66
115
|
end
|