kennel 1.75.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 +7 -0
- data/Readme.md +289 -0
- data/lib/kennel.rb +90 -0
- data/lib/kennel/api.rb +83 -0
- data/lib/kennel/file_cache.rb +53 -0
- data/lib/kennel/github_reporter.rb +49 -0
- data/lib/kennel/importer.rb +135 -0
- data/lib/kennel/models/base.rb +29 -0
- data/lib/kennel/models/dashboard.rb +209 -0
- data/lib/kennel/models/monitor.rb +219 -0
- data/lib/kennel/models/project.rb +31 -0
- data/lib/kennel/models/record.rb +94 -0
- data/lib/kennel/models/slo.rb +92 -0
- data/lib/kennel/models/team.rb +12 -0
- data/lib/kennel/optional_validations.rb +21 -0
- data/lib/kennel/progress.rb +34 -0
- data/lib/kennel/settings_as_methods.rb +86 -0
- data/lib/kennel/subclass_tracking.rb +19 -0
- data/lib/kennel/syncer.rb +260 -0
- data/lib/kennel/tasks.rb +148 -0
- data/lib/kennel/template_variables.rb +38 -0
- data/lib/kennel/unmuted_alerts.rb +89 -0
- data/lib/kennel/utils.rb +159 -0
- data/lib/kennel/version.rb +4 -0
- data/template/Readme.md +247 -0
- metadata +109 -0
@@ -0,0 +1,53 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# cache that reads everything from a single file
|
4
|
+
# to avoid doing multiple disk reads while iterating all definitions
|
5
|
+
# it also replaces updated keys and has an overall expiry to not keep deleted things forever
|
6
|
+
module Kennel
|
7
|
+
class FileCache
|
8
|
+
def initialize(file, cache_version)
|
9
|
+
@file = file
|
10
|
+
@cache_version = cache_version
|
11
|
+
@now = Time.now.to_i
|
12
|
+
@expires = @now + (30 * 24 * 60 * 60) # 1 month
|
13
|
+
end
|
14
|
+
|
15
|
+
def open
|
16
|
+
load_data
|
17
|
+
expire_old_data
|
18
|
+
yield self
|
19
|
+
ensure
|
20
|
+
persist
|
21
|
+
end
|
22
|
+
|
23
|
+
def fetch(key, key_version)
|
24
|
+
old_value, old_version = @data[key]
|
25
|
+
return old_value if old_version == [key_version, @cache_version]
|
26
|
+
|
27
|
+
new_value = yield
|
28
|
+
@data[key] = [new_value, [key_version, @cache_version], @expires]
|
29
|
+
new_value
|
30
|
+
end
|
31
|
+
|
32
|
+
private
|
33
|
+
|
34
|
+
def load_data
|
35
|
+
@data =
|
36
|
+
begin
|
37
|
+
Marshal.load(File.read(@file)) # rubocop:disable Security/MarshalLoad
|
38
|
+
rescue StandardError
|
39
|
+
{}
|
40
|
+
end
|
41
|
+
end
|
42
|
+
|
43
|
+
def persist
|
44
|
+
dir = File.dirname(@file)
|
45
|
+
FileUtils.mkdir_p(dir) unless File.directory?(dir)
|
46
|
+
File.write(@file, Marshal.dump(@data))
|
47
|
+
end
|
48
|
+
|
49
|
+
def expire_old_data
|
50
|
+
@data.reject! { |_, (_, _, ex)| ex < @now }
|
51
|
+
end
|
52
|
+
end
|
53
|
+
end
|
@@ -0,0 +1,49 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
# Not used in here, but in our templated repo ... so keeping it around for now.
|
3
|
+
module Kennel
|
4
|
+
class GithubReporter
|
5
|
+
MAX_COMMENT_SIZE = 65536
|
6
|
+
TRUNCATED_MSG = "\n```\n... (truncated)" # finish the code block so it look nice
|
7
|
+
|
8
|
+
class << self
|
9
|
+
def report(token, &block)
|
10
|
+
return yield unless token
|
11
|
+
new(token, Utils.capture_sh("git rev-parse HEAD").strip).report(&block)
|
12
|
+
end
|
13
|
+
end
|
14
|
+
|
15
|
+
def initialize(token, git_sha)
|
16
|
+
@token = token
|
17
|
+
@git_sha = git_sha
|
18
|
+
origin = ENV["PROJECT_REPOSITORY"] || Utils.capture_sh("git remote -v").split("\n").first
|
19
|
+
@repo_part = origin[%r{github\.com[:/](.+?)(\.git|$)}, 1] || raise("no origin found")
|
20
|
+
end
|
21
|
+
|
22
|
+
def report(&block)
|
23
|
+
output = Utils.strip_shell_control(Utils.tee_output(&block).strip)
|
24
|
+
rescue StandardError
|
25
|
+
output = "Error:\n#{$ERROR_INFO.message}"
|
26
|
+
raise
|
27
|
+
ensure
|
28
|
+
comment "```\n#{output || "Error"}\n```"
|
29
|
+
end
|
30
|
+
|
31
|
+
# https://developer.github.com/v3/repos/comments/#create-a-commit-comment
|
32
|
+
def comment(body)
|
33
|
+
# truncate to maximum allowed comment size for github to avoid 422
|
34
|
+
if body.bytesize > MAX_COMMENT_SIZE
|
35
|
+
body = body.byteslice(0, MAX_COMMENT_SIZE - TRUNCATED_MSG.bytesize) + TRUNCATED_MSG
|
36
|
+
end
|
37
|
+
|
38
|
+
post "commits/#{@git_sha}/comments", body: body
|
39
|
+
end
|
40
|
+
|
41
|
+
private
|
42
|
+
|
43
|
+
def post(path, data)
|
44
|
+
url = "https://api.github.com/repos/#{@repo_part}/#{path}"
|
45
|
+
response = Faraday.post(url, data.to_json, authorization: "token #{@token}")
|
46
|
+
raise "failed to POST to github:\n#{url} -> #{response.status}\n#{response.body}" unless response.status == 201
|
47
|
+
end
|
48
|
+
end
|
49
|
+
end
|
@@ -0,0 +1,135 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Kennel
|
4
|
+
class Importer
|
5
|
+
TITLES = [:name, :title].freeze
|
6
|
+
SORT_ORDER = [*TITLES, :id, :kennel_id, :type, :tags, :query, *Syncer::TRACKING_FIELDS, :template_variables].freeze
|
7
|
+
|
8
|
+
def initialize(api)
|
9
|
+
@api = api
|
10
|
+
end
|
11
|
+
|
12
|
+
def import(resource, id)
|
13
|
+
if ["screen", "dash"].include?(resource)
|
14
|
+
raise ArgumentError, "resource 'screen' and 'dash' are deprecated, use 'dashboard'"
|
15
|
+
end
|
16
|
+
|
17
|
+
model =
|
18
|
+
begin
|
19
|
+
Kennel::Models.const_get(resource.capitalize)
|
20
|
+
rescue NameError
|
21
|
+
raise ArgumentError, "#{resource} is not supported"
|
22
|
+
end
|
23
|
+
|
24
|
+
data = @api.show(model.api_resource, id)
|
25
|
+
id = data.fetch(:id) # keep native value
|
26
|
+
model.normalize({}, data) # removes id
|
27
|
+
data[:id] = id
|
28
|
+
|
29
|
+
title_field = TITLES.detect { |f| data[f] }
|
30
|
+
title = data.fetch(title_field)
|
31
|
+
title.tr!(Kennel::Models::Record::LOCK, "") # avoid double lock icon
|
32
|
+
|
33
|
+
# calculate or reuse kennel_id
|
34
|
+
# TODO: this is copy-pasted from syncer, need to find a nice way to reuse it
|
35
|
+
tracking_field = Syncer::TRACKING_FIELDS.detect { |f| data[f] }
|
36
|
+
data[:kennel_id] =
|
37
|
+
if tracking_field && data[tracking_field].sub!(/\n?-- Managed by kennel (\S+:\S+).*/, "")
|
38
|
+
$1.split(":").last
|
39
|
+
else
|
40
|
+
Kennel::Utils.parameterize(title)
|
41
|
+
end
|
42
|
+
|
43
|
+
case resource
|
44
|
+
when "monitor"
|
45
|
+
# flatten monitor options so they are all on the base
|
46
|
+
data.merge!(data.delete(:options))
|
47
|
+
data.merge!(data.delete(:thresholds) || {})
|
48
|
+
[:notify_no_data, :notify_audit].each { |k| data.delete(k) if data[k] } # monitor uses true by default
|
49
|
+
data = data.slice(*model.instance_methods)
|
50
|
+
|
51
|
+
# make query use critical method if it matches
|
52
|
+
critical = data[:critical]
|
53
|
+
query = data[:query]
|
54
|
+
if query && critical
|
55
|
+
query.sub!(/([><=]) (#{Regexp.escape(critical.to_f.to_s)}|#{Regexp.escape(critical.to_i.to_s)})$/, "\\1 \#{critical}")
|
56
|
+
end
|
57
|
+
|
58
|
+
data[:type] = "query alert" if data[:type] == "metric alert"
|
59
|
+
when "dashboard"
|
60
|
+
widgets = data[:widgets]&.flat_map { |widget| widget.dig(:definition, :widgets) || [widget] }
|
61
|
+
widgets&.each { |widget| dry_up_query!(widget) }
|
62
|
+
end
|
63
|
+
|
64
|
+
# simplify template_variables to array of string when possible
|
65
|
+
if vars = data[:template_variables]
|
66
|
+
vars.map! { |v| v[:default] == "*" && v[:prefix] == v[:name] ? v[:name] : v }
|
67
|
+
end
|
68
|
+
|
69
|
+
pretty = pretty_print(data).lstrip.gsub("\\#", "#")
|
70
|
+
<<~RUBY
|
71
|
+
#{model.name}.new(
|
72
|
+
self,
|
73
|
+
#{pretty}
|
74
|
+
)
|
75
|
+
RUBY
|
76
|
+
end
|
77
|
+
|
78
|
+
private
|
79
|
+
|
80
|
+
# reduce duplication in imports by using dry `q: :metadata` when possible
|
81
|
+
def dry_up_query!(widget)
|
82
|
+
(widget.dig(:definition, :requests) || []).each do |request|
|
83
|
+
next unless request.is_a?(Hash)
|
84
|
+
next unless metadata = request[:metadata]
|
85
|
+
next unless query = request[:q]&.dup
|
86
|
+
metadata.each do |m|
|
87
|
+
next unless exp = m[:expression]
|
88
|
+
query.sub!(exp, "")
|
89
|
+
end
|
90
|
+
request[:q] = :metadata if query.delete(", ") == ""
|
91
|
+
end
|
92
|
+
end
|
93
|
+
|
94
|
+
def pretty_print(hash)
|
95
|
+
sort_widgets hash
|
96
|
+
|
97
|
+
sort_hash(hash).map do |k, v|
|
98
|
+
pretty_value =
|
99
|
+
if v.is_a?(Hash) || (v.is_a?(Array) && !v.all? { |e| e.is_a?(String) })
|
100
|
+
# update answer here when changing https://stackoverflow.com/questions/8842546/best-way-to-pretty-print-a-hash
|
101
|
+
# (exclude last indent gsub)
|
102
|
+
pretty = JSON.pretty_generate(v)
|
103
|
+
.gsub(": null", ": nil")
|
104
|
+
.gsub(/(^\s*)"([a-zA-Z][a-zA-Z\d_]*)":/, "\\1\\2:") # "foo": 1 -> foo: 1
|
105
|
+
.gsub(/: \[\n\s+\]/, ": []") # empty arrays on a single line
|
106
|
+
.gsub(/^/, " ") # indent
|
107
|
+
.gsub('q: "metadata"', "q: :metadata") # bring symbols back
|
108
|
+
|
109
|
+
"\n#{pretty}\n "
|
110
|
+
elsif k == :message
|
111
|
+
"\n <<~TEXT\n#{v.each_line.map { |l| l.strip.empty? ? "\n" : " #{l}" }.join}\n \#{super()}\n TEXT\n "
|
112
|
+
elsif k == :tags
|
113
|
+
" super() + #{v.inspect} "
|
114
|
+
else
|
115
|
+
" #{v.inspect} "
|
116
|
+
end
|
117
|
+
" #{k}: -> {#{pretty_value}}"
|
118
|
+
end.join(",\n")
|
119
|
+
end
|
120
|
+
|
121
|
+
# sort dashboard widgets + nesting
|
122
|
+
def sort_widgets(outer)
|
123
|
+
outer[:widgets]&.each do |widgets|
|
124
|
+
definition = widgets[:definition]
|
125
|
+
definition.replace sort_hash(definition)
|
126
|
+
sort_widgets definition
|
127
|
+
end
|
128
|
+
end
|
129
|
+
|
130
|
+
# important to the front and rest deterministic
|
131
|
+
def sort_hash(hash)
|
132
|
+
Hash[hash.sort_by { |k, _| [SORT_ORDER.index(k) || 999, k] }]
|
133
|
+
end
|
134
|
+
end
|
135
|
+
end
|
@@ -0,0 +1,29 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
require "hashdiff"
|
3
|
+
|
4
|
+
module Kennel
|
5
|
+
module Models
|
6
|
+
class Base
|
7
|
+
extend SubclassTracking
|
8
|
+
include SettingsAsMethods
|
9
|
+
|
10
|
+
SETTING_OVERRIDABLE_METHODS = [:name, :kennel_id].freeze
|
11
|
+
|
12
|
+
def kennel_id
|
13
|
+
name = self.class.name
|
14
|
+
if name.start_with?("Kennel::")
|
15
|
+
raise_with_location ArgumentError, "Set :kennel_id"
|
16
|
+
end
|
17
|
+
@kennel_id ||= Utils.snake_case name
|
18
|
+
end
|
19
|
+
|
20
|
+
def name
|
21
|
+
self.class.name
|
22
|
+
end
|
23
|
+
|
24
|
+
def to_json # rubocop:disable Lint/ToJSON
|
25
|
+
raise NotImplementedError, "Use as_json"
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
@@ -0,0 +1,209 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
module Kennel
|
3
|
+
module Models
|
4
|
+
class Dashboard < Record
|
5
|
+
include TemplateVariables
|
6
|
+
include OptionalValidations
|
7
|
+
|
8
|
+
API_LIST_INCOMPLETE = true
|
9
|
+
DASHBOARD_DEFAULTS = { template_variables: [] }.freeze
|
10
|
+
READONLY_ATTRIBUTES = superclass::READONLY_ATTRIBUTES + [
|
11
|
+
:author_handle, :author_name, :modified_at, :url, :is_read_only, :notify_list
|
12
|
+
]
|
13
|
+
REQUEST_DEFAULTS = {
|
14
|
+
style: { line_width: "normal", palette: "dog_classic", line_type: "solid" }
|
15
|
+
}.freeze
|
16
|
+
WIDGET_DEFAULTS = {
|
17
|
+
"timeseries" => { show_legend: false, legend_size: "0" },
|
18
|
+
"note" => { background_color: "white", font_size: "14", show_tick: false, tick_edge: "left", tick_pos: "50%", text_align: "left" }
|
19
|
+
}.freeze
|
20
|
+
SUPPORTED_DEFINITION_OPTIONS = [:events, :markers, :precision].freeze
|
21
|
+
|
22
|
+
DEFAULTS = {
|
23
|
+
template_variable_presets: nil
|
24
|
+
}.freeze
|
25
|
+
|
26
|
+
settings :title, :description, :definitions, :widgets, :layout_type, :template_variable_presets
|
27
|
+
|
28
|
+
defaults(
|
29
|
+
description: -> { "" },
|
30
|
+
definitions: -> { [] },
|
31
|
+
widgets: -> { [] },
|
32
|
+
template_variable_presets: -> { DEFAULTS.fetch(:template_variable_presets) },
|
33
|
+
id: -> { nil }
|
34
|
+
)
|
35
|
+
|
36
|
+
class << self
|
37
|
+
def api_resource
|
38
|
+
"dashboard"
|
39
|
+
end
|
40
|
+
|
41
|
+
def normalize(expected, actual)
|
42
|
+
super
|
43
|
+
|
44
|
+
ignore_default(expected, actual, DEFAULTS)
|
45
|
+
|
46
|
+
widgets_pairs(expected, actual).each do |pair|
|
47
|
+
# conditional_formats ordering is randomly changed by datadog, compare a stable ordering
|
48
|
+
pair.each do |widgets|
|
49
|
+
widgets.each do |widget|
|
50
|
+
if formats = widget.dig(:definition, :conditional_formats)
|
51
|
+
widget[:definition][:conditional_formats] = formats.sort_by(&:hash)
|
52
|
+
end
|
53
|
+
end
|
54
|
+
end
|
55
|
+
|
56
|
+
ignore_widget_defaults pair
|
57
|
+
|
58
|
+
ignore_request_defaults(*pair)
|
59
|
+
|
60
|
+
# ids are kinda random so we always discard them
|
61
|
+
pair.each { |widgets| widgets.each { |w| w.delete(:id) } }
|
62
|
+
end
|
63
|
+
end
|
64
|
+
|
65
|
+
private
|
66
|
+
|
67
|
+
def ignore_widget_defaults(pair)
|
68
|
+
pair.map(&:size).max.times do |i|
|
69
|
+
types = pair.map { |w| w.dig(i, :definition, :type) }.uniq
|
70
|
+
next unless types.size == 1
|
71
|
+
next unless defaults = WIDGET_DEFAULTS[types.first]
|
72
|
+
ignore_defaults(pair[0], pair[1], defaults, nesting: :definition)
|
73
|
+
end
|
74
|
+
end
|
75
|
+
|
76
|
+
# discard styles/conditional_formats/aggregator if nothing would change when we applied (both are default or nil)
|
77
|
+
def ignore_request_defaults(expected, actual)
|
78
|
+
[expected.size, actual.size].max.times do |i|
|
79
|
+
a_r = actual.dig(i, :definition, :requests) || []
|
80
|
+
e_r = expected.dig(i, :definition, :requests) || []
|
81
|
+
ignore_defaults e_r, a_r, REQUEST_DEFAULTS
|
82
|
+
end
|
83
|
+
end
|
84
|
+
|
85
|
+
def ignore_defaults(expected, actual, defaults, nesting: nil)
|
86
|
+
[expected.size, actual.size].max.times do |i|
|
87
|
+
e = expected.dig(i, *nesting) || {}
|
88
|
+
a = actual.dig(i, *nesting) || {}
|
89
|
+
ignore_default(e, a, defaults)
|
90
|
+
end
|
91
|
+
end
|
92
|
+
|
93
|
+
# expand nested widgets into expected/actual pairs for default resolution
|
94
|
+
# [a, e] -> [[a-w, e-w], [a-w1-w1, e-w1-w1], ...]
|
95
|
+
def widgets_pairs(*pair)
|
96
|
+
result = [pair.map { |d| d[:widgets] || [] }]
|
97
|
+
slots = result[0].map(&:size).max
|
98
|
+
slots.times do |i|
|
99
|
+
nested = pair.map { |d| d.dig(:widgets, i, :definition, :widgets) || [] }
|
100
|
+
result << nested if nested.any?(&:any?)
|
101
|
+
end
|
102
|
+
result
|
103
|
+
end
|
104
|
+
end
|
105
|
+
|
106
|
+
def as_json
|
107
|
+
return @json if @json
|
108
|
+
all_widgets = render_definitions + widgets
|
109
|
+
expand_q all_widgets
|
110
|
+
|
111
|
+
@json = {
|
112
|
+
layout_type: layout_type,
|
113
|
+
title: "#{title}#{LOCK}",
|
114
|
+
description: description,
|
115
|
+
template_variables: render_template_variables,
|
116
|
+
template_variable_presets: template_variable_presets,
|
117
|
+
widgets: all_widgets
|
118
|
+
}
|
119
|
+
|
120
|
+
@json[:id] = id if id
|
121
|
+
|
122
|
+
validate_json(@json) if validate
|
123
|
+
|
124
|
+
@json
|
125
|
+
end
|
126
|
+
|
127
|
+
def url(id)
|
128
|
+
Utils.path_to_url "/dashboard/#{id}"
|
129
|
+
end
|
130
|
+
|
131
|
+
def self.parse_url(url)
|
132
|
+
url[/\/dashboard\/([a-z\d-]+)/, 1]
|
133
|
+
end
|
134
|
+
|
135
|
+
def resolve_linked_tracking_ids!(id_map, **args)
|
136
|
+
widgets = as_json[:widgets].flat_map { |w| [w, *w.dig(:definition, :widgets) || []] }
|
137
|
+
widgets.each do |widget|
|
138
|
+
next unless definition = widget[:definition]
|
139
|
+
case definition[:type]
|
140
|
+
when "uptime"
|
141
|
+
if ids = definition[:monitor_ids]
|
142
|
+
definition[:monitor_ids] = ids.map do |id|
|
143
|
+
tracking_id?(id) ? resolve_link(id, :monitor, id_map, **args) : id
|
144
|
+
end
|
145
|
+
end
|
146
|
+
when "alert_graph"
|
147
|
+
if (id = definition[:alert_id]) && tracking_id?(id)
|
148
|
+
definition[:alert_id] = resolve_link(id, :monitor, id_map, **args).to_s
|
149
|
+
end
|
150
|
+
when "slo"
|
151
|
+
if (id = definition[:slo_id]) && tracking_id?(id)
|
152
|
+
definition[:slo_id] = resolve_link(id, :slo, id_map, **args).to_s
|
153
|
+
end
|
154
|
+
end
|
155
|
+
end
|
156
|
+
end
|
157
|
+
|
158
|
+
private
|
159
|
+
|
160
|
+
def tracking_id?(id)
|
161
|
+
id.is_a?(String) && id.include?(":")
|
162
|
+
end
|
163
|
+
|
164
|
+
# creates queries from metadata to avoid having to keep q and expression in sync
|
165
|
+
#
|
166
|
+
# {q: :metadata, metadata: [{expression: "sum:bar", alias_name: "foo"}, ...], }
|
167
|
+
# -> {q: "sum:bar, ...", metadata: ..., }
|
168
|
+
def expand_q(widgets)
|
169
|
+
widgets = widgets.flat_map { |w| w.dig(:definition, :widgets) || w } # expand groups
|
170
|
+
widgets.each do |w|
|
171
|
+
w.dig(:definition, :requests)&.each do |request|
|
172
|
+
next unless request.is_a?(Hash) && request[:q] == :metadata
|
173
|
+
request[:q] = request.fetch(:metadata).map { |m| m.fetch(:expression) }.join(", ")
|
174
|
+
end
|
175
|
+
end
|
176
|
+
end
|
177
|
+
|
178
|
+
def validate_json(data)
|
179
|
+
super
|
180
|
+
|
181
|
+
validate_template_variables data, :widgets
|
182
|
+
|
183
|
+
# Avoid diff from datadog presets sorting.
|
184
|
+
presets = data[:template_variable_presets]
|
185
|
+
invalid! "template_variable_presets must be sorted by name" if presets && presets != presets.sort_by { |p| p[:name] }
|
186
|
+
end
|
187
|
+
|
188
|
+
def render_definitions
|
189
|
+
definitions.map do |title, type, display_type, queries, options = {}, ignored = nil|
|
190
|
+
# validate inputs
|
191
|
+
if ignored || (!title || !type || !queries || !options.is_a?(Hash))
|
192
|
+
raise ArgumentError, "Expected exactly 5 arguments for each definition (title, type, display_type, queries, options)"
|
193
|
+
end
|
194
|
+
if (SUPPORTED_DEFINITION_OPTIONS | options.keys) != SUPPORTED_DEFINITION_OPTIONS
|
195
|
+
raise ArgumentError, "Supported options are: #{SUPPORTED_DEFINITION_OPTIONS.map(&:inspect).join(", ")}"
|
196
|
+
end
|
197
|
+
|
198
|
+
# build definition
|
199
|
+
requests = Array(queries).map do |q|
|
200
|
+
request = { q: q }
|
201
|
+
request[:display_type] = display_type if display_type
|
202
|
+
request
|
203
|
+
end
|
204
|
+
{ definition: { title: title, type: type, requests: requests, **options } }
|
205
|
+
end
|
206
|
+
end
|
207
|
+
end
|
208
|
+
end
|
209
|
+
end
|