gitlab_internal_events_cli 0.0.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.
- checksums.yaml +7 -0
- data/.rspec +3 -0
- data/.tool-versions +1 -0
- data/Gemfile +11 -0
- data/Gemfile.lock +90 -0
- data/LICENSE.txt +19 -0
- data/README.md +164 -0
- data/Rakefile +10 -0
- data/exe/gitlab-internal-events-cli +7 -0
- data/gitlab_internal_events_cli.gemspec +39 -0
- data/lib/gitlab_internal_events_cli/cli.rb +59 -0
- data/lib/gitlab_internal_events_cli/configuration.rb +115 -0
- data/lib/gitlab_internal_events_cli/event.rb +73 -0
- data/lib/gitlab_internal_events_cli/flows/event_definer.rb +306 -0
- data/lib/gitlab_internal_events_cli/flows/flow_advisor.rb +90 -0
- data/lib/gitlab_internal_events_cli/flows/metric_definer.rb +468 -0
- data/lib/gitlab_internal_events_cli/flows/usage_viewer.rb +474 -0
- data/lib/gitlab_internal_events_cli/gitlab_prompt.rb +9 -0
- data/lib/gitlab_internal_events_cli/global_state.rb +63 -0
- data/lib/gitlab_internal_events_cli/helpers/cli_inputs.rb +138 -0
- data/lib/gitlab_internal_events_cli/helpers/event_options.rb +63 -0
- data/lib/gitlab_internal_events_cli/helpers/files.rb +84 -0
- data/lib/gitlab_internal_events_cli/helpers/formatting.rb +166 -0
- data/lib/gitlab_internal_events_cli/helpers/group_ownership.rb +160 -0
- data/lib/gitlab_internal_events_cli/helpers/metric_options.rb +253 -0
- data/lib/gitlab_internal_events_cli/helpers/schema_loader.rb +25 -0
- data/lib/gitlab_internal_events_cli/helpers/service_ping_dashboards.rb +22 -0
- data/lib/gitlab_internal_events_cli/helpers.rb +47 -0
- data/lib/gitlab_internal_events_cli/http_cache.rb +52 -0
- data/lib/gitlab_internal_events_cli/metric.rb +406 -0
- data/lib/gitlab_internal_events_cli/schema_resolver.rb +25 -0
- data/lib/gitlab_internal_events_cli/subflows/database_metric_definer.rb +71 -0
- data/lib/gitlab_internal_events_cli/subflows/event_metric_definer.rb +258 -0
- data/lib/gitlab_internal_events_cli/text/event_definer.rb +166 -0
- data/lib/gitlab_internal_events_cli/text/flow_advisor.rb +64 -0
- data/lib/gitlab_internal_events_cli/text/metric_definer.rb +138 -0
- data/lib/gitlab_internal_events_cli/time_framed_key_path.rb +18 -0
- data/lib/gitlab_internal_events_cli/version.rb +5 -0
- data/lib/gitlab_internal_events_cli.rb +36 -0
- metadata +170 -0
|
@@ -0,0 +1,406 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GitlabInternalEventsCli
|
|
4
|
+
NEW_METRIC_FIELDS = %i[
|
|
5
|
+
key_path
|
|
6
|
+
description
|
|
7
|
+
product_group
|
|
8
|
+
product_categories
|
|
9
|
+
performance_indicator_type
|
|
10
|
+
value_type
|
|
11
|
+
status
|
|
12
|
+
milestone
|
|
13
|
+
introduced_by_url
|
|
14
|
+
time_frame
|
|
15
|
+
data_source
|
|
16
|
+
data_category
|
|
17
|
+
tiers
|
|
18
|
+
events
|
|
19
|
+
instrumentation_class
|
|
20
|
+
].freeze
|
|
21
|
+
|
|
22
|
+
ADDITIONAL_METRIC_FIELDS = %i[
|
|
23
|
+
milestone_removed
|
|
24
|
+
removed_by_url
|
|
25
|
+
removed_by
|
|
26
|
+
repair_issue_url
|
|
27
|
+
value_json_schema
|
|
28
|
+
name
|
|
29
|
+
].freeze
|
|
30
|
+
|
|
31
|
+
METRIC_DEFAULTS = {
|
|
32
|
+
product_group: nil,
|
|
33
|
+
introduced_by_url: 'TODO',
|
|
34
|
+
value_type: 'number',
|
|
35
|
+
status: 'active',
|
|
36
|
+
data_source: 'internal_events',
|
|
37
|
+
data_category: 'optional',
|
|
38
|
+
performance_indicator_type: []
|
|
39
|
+
}.freeze
|
|
40
|
+
|
|
41
|
+
# These keys will always be included in the definition yaml
|
|
42
|
+
ExistingMetric = Struct.new(*NEW_METRIC_FIELDS, *ADDITIONAL_METRIC_FIELDS, :file_path, keyword_init: true) do
|
|
43
|
+
def identifier
|
|
44
|
+
events&.dig(0, 'unique')&.chomp('.id')
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def actions
|
|
48
|
+
events&.map { |event| event['name'] }
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def filters
|
|
52
|
+
events&.map do |event|
|
|
53
|
+
[event['name'], event['filter'] || {}]
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def filtered?
|
|
58
|
+
!!filters&.any? { |(_action, filter)| filter&.any? }
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def time_frame
|
|
62
|
+
self[:time_frame] || 'all'
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Enables comparison with new metrics
|
|
66
|
+
def unique_ids
|
|
67
|
+
prefix = [
|
|
68
|
+
operator,
|
|
69
|
+
(actions || []).sort.join('+'),
|
|
70
|
+
'filter-',
|
|
71
|
+
filtered?
|
|
72
|
+
].join('_')
|
|
73
|
+
|
|
74
|
+
Array(time_frame).map { |t| prefix + t }
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def operator
|
|
78
|
+
events&.dig(0, 'operator') || "count(#{identifier})"
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
NewMetric = Struct.new(*NEW_METRIC_FIELDS, :identifier, :actions, :key, :filters, :operator, keyword_init: true) do
|
|
83
|
+
def formatted_output
|
|
84
|
+
extra_keys = event_metric? ? { events: events } : {}
|
|
85
|
+
|
|
86
|
+
# These keys will always be included in the definition yaml
|
|
87
|
+
METRIC_DEFAULTS
|
|
88
|
+
.merge(to_h.compact)
|
|
89
|
+
.merge(
|
|
90
|
+
time_frame: assign_time_frame,
|
|
91
|
+
key_path: key_path
|
|
92
|
+
).merge(extra_keys)
|
|
93
|
+
.slice(*NEW_METRIC_FIELDS)
|
|
94
|
+
.transform_keys(&:to_s)
|
|
95
|
+
.to_yaml(line_width: 150)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def file_path
|
|
99
|
+
File.join(
|
|
100
|
+
*[
|
|
101
|
+
distribution_path,
|
|
102
|
+
'config',
|
|
103
|
+
'metrics',
|
|
104
|
+
time_frame.directory_name,
|
|
105
|
+
file_name
|
|
106
|
+
].compact
|
|
107
|
+
)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def distribution_path
|
|
111
|
+
'ee' unless tiers.include?('free')
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def file_name
|
|
115
|
+
name = event_metric? ? key.value : key_path
|
|
116
|
+
"#{name}.yml"
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def key_path
|
|
120
|
+
event_metric? ? key.full_path : self[:key]
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def time_frame
|
|
124
|
+
Metric::TimeFrames.new(self[:time_frame])
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def identifier
|
|
128
|
+
Metric::Identifier.new(self[:identifier])
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def key
|
|
132
|
+
Metric::Key.new(self[:key] || actions, time_frame, identifier, operator)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def filters
|
|
136
|
+
Metric::Filters.new(self[:filters])
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def operator
|
|
140
|
+
Metric::Operator.new(self[:operator])
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# Enables comparison with existing metrics
|
|
144
|
+
def unique_ids
|
|
145
|
+
prefix = [
|
|
146
|
+
operator.reference(identifier),
|
|
147
|
+
actions.sort.join('+'),
|
|
148
|
+
'filter-',
|
|
149
|
+
filtered?
|
|
150
|
+
].join('_')
|
|
151
|
+
|
|
152
|
+
time_frame.value.map { |t| prefix + t }
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# Returns value for the `events` key in the metric definition.
|
|
156
|
+
# Requires #actions or #filters to be set by the caller first.
|
|
157
|
+
#
|
|
158
|
+
# @return [Hash]
|
|
159
|
+
def events
|
|
160
|
+
if filters.assigned?
|
|
161
|
+
self[:filters].map { |(action, filter)| event_params(action, filter) }
|
|
162
|
+
else
|
|
163
|
+
actions.map { |action| event_params(action) }
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def event_params(action, filter = nil)
|
|
168
|
+
params = { 'name' => action }
|
|
169
|
+
params['unique'] = identifier.reference if operator.value == 'unique_count'
|
|
170
|
+
params['filter'] = filter if filter&.any?
|
|
171
|
+
params['operator'] = operator.reference(identifier) if operator.value == 'sum'
|
|
172
|
+
|
|
173
|
+
params
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def actions
|
|
177
|
+
self[:actions] || []
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# How to interpret different values for filters:
|
|
181
|
+
# nil --> not expected, assigned or filtered
|
|
182
|
+
# (metric not initialized with filters)
|
|
183
|
+
# [] --> both expected and filtered
|
|
184
|
+
# (metric initialized with filters, but not yet assigned by user)
|
|
185
|
+
# [['event', {}]] --> not expected, assigned or filtered
|
|
186
|
+
# (filters were expected, but then skipped by user)
|
|
187
|
+
# [['event', { 'label' => 'a' }]] --> both assigned and filtered
|
|
188
|
+
# (filters exist for any event; user is done assigning)
|
|
189
|
+
def filtered?
|
|
190
|
+
filters.assigned? || filters.expected?
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def filters_expected?
|
|
194
|
+
filters.expected?
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# Automatically prepended to all new descriptions
|
|
198
|
+
# ex) Total count of
|
|
199
|
+
# ex) Weekly/Monthly count of unique
|
|
200
|
+
# ex) Count of
|
|
201
|
+
def description_prefix
|
|
202
|
+
return unless event_metric?
|
|
203
|
+
|
|
204
|
+
[
|
|
205
|
+
(time_frame.description if time_frame.single?),
|
|
206
|
+
operator.description,
|
|
207
|
+
*(identifier.plural if identifier.default?)
|
|
208
|
+
].compact.join(' ').capitalize
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
# Provides simplified but technically accurate description
|
|
212
|
+
# to be used before the user has provided a description
|
|
213
|
+
def technical_description
|
|
214
|
+
return unless event_metric?
|
|
215
|
+
|
|
216
|
+
event_name = actions.first if events.length == 1 && !filtered?
|
|
217
|
+
event_name ||= 'the selected events'
|
|
218
|
+
[
|
|
219
|
+
(time_frame.description if time_frame.single?),
|
|
220
|
+
operator.description,
|
|
221
|
+
(identifier.description % event_name).to_s
|
|
222
|
+
].compact.join(' ').capitalize
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def bulk_assign(key_value_pairs)
|
|
226
|
+
key_value_pairs.each { |key, value| self[key] = value }
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
# Maintain current functionality of string time_frame for backward compatibility
|
|
230
|
+
# TODO: Remove once we can deduplicate and merge metric files
|
|
231
|
+
def assign_time_frame
|
|
232
|
+
time_frame.single? ? time_frame.value.first : time_frame.value
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
def event_metric?
|
|
236
|
+
data_source == 'internal_events'
|
|
237
|
+
end
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
class Metric
|
|
241
|
+
TimeFrames = Struct.new(:value) do
|
|
242
|
+
def description
|
|
243
|
+
(%w[all 28d 7d] & value).map do |time_trame|
|
|
244
|
+
TimeFramedKeyPath::METRIC_TIME_FRAME_DESC[time_trame].capitalize
|
|
245
|
+
end.join('/')
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
def directory_name
|
|
249
|
+
return 'counts_all' unless single?
|
|
250
|
+
|
|
251
|
+
"counts_#{value.first}"
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
def key_path
|
|
255
|
+
description&.downcase if single? && %w[7d 28d].include?(value.first)
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
# TODO: Delete once we are able to deduplicate and merge metric files
|
|
259
|
+
def single?
|
|
260
|
+
!value.is_a?(Array) || value.length == 1
|
|
261
|
+
end
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
Identifier = Struct.new(:value) do
|
|
265
|
+
# returns a description of the identifier with appropriate
|
|
266
|
+
# grammar to interpolate a description of events
|
|
267
|
+
def description
|
|
268
|
+
if value.nil?
|
|
269
|
+
'%s occurrences'
|
|
270
|
+
elsif value == 'user'
|
|
271
|
+
'users who triggered %s'
|
|
272
|
+
elsif %w[project namespace].include?(value)
|
|
273
|
+
"#{plural} where %s occurred"
|
|
274
|
+
else
|
|
275
|
+
"#{plural} from %s occurrences"
|
|
276
|
+
end
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
# handles generic pluralization for unknown indentifers
|
|
280
|
+
def plural
|
|
281
|
+
default? ? "#{value}s" : "values for '#{value}'"
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
# returns a slug which can be used in the
|
|
285
|
+
# metric's key_path and filepath
|
|
286
|
+
def key_path(operator)
|
|
287
|
+
case operator.value
|
|
288
|
+
when 'unique_count'
|
|
289
|
+
"distinct_#{reference.tr('.', '_')}_from"
|
|
290
|
+
when 'count'
|
|
291
|
+
'total'
|
|
292
|
+
when 'sum'
|
|
293
|
+
"#{reference.tr('.', '_')}_from"
|
|
294
|
+
end
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
# Returns the identifier string that will be included in the yml
|
|
298
|
+
def reference
|
|
299
|
+
default? ? "#{value}.id" : value
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
# Refers to the top-level identifiers not included in
|
|
303
|
+
# additional_properties
|
|
304
|
+
def default?
|
|
305
|
+
%w[user project namespace].include?(value)
|
|
306
|
+
end
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
Key = Struct.new(:events, :time_frame, :identifier, :operator) do
|
|
310
|
+
# @param name_to_display [String] return the key with the
|
|
311
|
+
# provided name instead of a list of event names
|
|
312
|
+
def value(name_to_display = nil)
|
|
313
|
+
[
|
|
314
|
+
operator.verb,
|
|
315
|
+
identifier&.key_path(operator),
|
|
316
|
+
name_to_display || name_for_events,
|
|
317
|
+
time_frame&.key_path
|
|
318
|
+
].compact.join('_')
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
def full_path
|
|
322
|
+
"#{operator.key_path}.#{value}"
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
private
|
|
326
|
+
|
|
327
|
+
# Refers to the middle portion of a metric's `key_path`
|
|
328
|
+
# pertaining to the relevent events; This does not include
|
|
329
|
+
# identifier/time_frame/etc
|
|
330
|
+
def name_for_events
|
|
331
|
+
# user may have defined a different name for events
|
|
332
|
+
return events unless events.respond_to?(:join)
|
|
333
|
+
|
|
334
|
+
events.join('_and_')
|
|
335
|
+
end
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
Filters = Struct.new(:filters) do
|
|
339
|
+
def expected?
|
|
340
|
+
filters == []
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
def assigned?
|
|
344
|
+
!!filters&.any? { |(_action, filter)| filter.any? }
|
|
345
|
+
end
|
|
346
|
+
|
|
347
|
+
def descriptions
|
|
348
|
+
Array(filters).filter_map do |(action, filter)|
|
|
349
|
+
next action if filter.none?
|
|
350
|
+
|
|
351
|
+
"#{action}(#{describe_filter(filter)})"
|
|
352
|
+
end.sort_by(&:length)
|
|
353
|
+
end
|
|
354
|
+
|
|
355
|
+
def describe_filter(filter)
|
|
356
|
+
filter.map { |k, v| "#{k}=#{v}" }.join(',')
|
|
357
|
+
end
|
|
358
|
+
end
|
|
359
|
+
|
|
360
|
+
Operator = Struct.new(:value) do
|
|
361
|
+
def description
|
|
362
|
+
if qualifier
|
|
363
|
+
"#{verb} of #{qualifier}"
|
|
364
|
+
else
|
|
365
|
+
"#{verb} of"
|
|
366
|
+
end
|
|
367
|
+
end
|
|
368
|
+
|
|
369
|
+
def verb
|
|
370
|
+
value == 'unique_count' ? 'count' : value
|
|
371
|
+
end
|
|
372
|
+
|
|
373
|
+
def reference(identifier)
|
|
374
|
+
"#{verb}(#{identifier.value})"
|
|
375
|
+
end
|
|
376
|
+
|
|
377
|
+
def key_path
|
|
378
|
+
case value
|
|
379
|
+
when 'unique_count'
|
|
380
|
+
'redis_hll_counters'
|
|
381
|
+
when 'count'
|
|
382
|
+
'counts'
|
|
383
|
+
when 'sum'
|
|
384
|
+
'sums'
|
|
385
|
+
end
|
|
386
|
+
end
|
|
387
|
+
|
|
388
|
+
def qualifier
|
|
389
|
+
case value
|
|
390
|
+
when 'unique_count'
|
|
391
|
+
'unique'
|
|
392
|
+
when 'sum'
|
|
393
|
+
'all'
|
|
394
|
+
end
|
|
395
|
+
end
|
|
396
|
+
end
|
|
397
|
+
|
|
398
|
+
def self.parse(**args)
|
|
399
|
+
ExistingMetric.new(**args)
|
|
400
|
+
end
|
|
401
|
+
|
|
402
|
+
def self.new(**args)
|
|
403
|
+
NewMetric.new(**args)
|
|
404
|
+
end
|
|
405
|
+
end
|
|
406
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GitlabInternalEventsCli
|
|
4
|
+
class SchemaResolver
|
|
5
|
+
def initialize(base_uri)
|
|
6
|
+
@base_uri = base_uri
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def call(uri)
|
|
10
|
+
full_url = uri.to_s.sub('json-schemer://', 'https://')
|
|
11
|
+
|
|
12
|
+
unless full_url.start_with?('http')
|
|
13
|
+
ref_path = uri.respond_to?(:path) ? uri.path.sub(%r{^/}, '') : uri.to_s
|
|
14
|
+
full_url = URI.join(@base_uri, ref_path).to_s
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
schema_content = HttpCache.get(full_url)
|
|
18
|
+
return nil unless schema_content
|
|
19
|
+
|
|
20
|
+
JSON.parse(schema_content)
|
|
21
|
+
rescue StandardError
|
|
22
|
+
nil
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GitlabInternalEventsCli
|
|
4
|
+
module Subflows
|
|
5
|
+
class DatabaseMetricDefiner
|
|
6
|
+
include Helpers
|
|
7
|
+
include Text::MetricDefiner
|
|
8
|
+
|
|
9
|
+
CLASS_NAME_REGEX = /\A[a-zA-Z]+\z/
|
|
10
|
+
|
|
11
|
+
attr_reader :metric
|
|
12
|
+
|
|
13
|
+
def initialize(cli)
|
|
14
|
+
@cli = cli
|
|
15
|
+
@metric = nil
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def run
|
|
19
|
+
@metric = Metric.new
|
|
20
|
+
metric.data_source = 'database'
|
|
21
|
+
|
|
22
|
+
prompt_for_instrumentation_class
|
|
23
|
+
prompt_for_time_frame
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
attr_reader :cli
|
|
29
|
+
|
|
30
|
+
def prompt_for_instrumentation_class
|
|
31
|
+
cli.say INSTRUMENTATION_CLASS_INTRO
|
|
32
|
+
cli.say <<~TEXT
|
|
33
|
+
|
|
34
|
+
#{input_opts[:prefix]} What should be the instrumentation class for this metric? #{input_required_text}
|
|
35
|
+
|
|
36
|
+
TEXT
|
|
37
|
+
|
|
38
|
+
metric.instrumentation_class = prompt_for_text(' Instrumentation class: ') do |q|
|
|
39
|
+
q.required true
|
|
40
|
+
q.messages[:required?] = INSTRUMENTATION_CLASS_HELP
|
|
41
|
+
q.messages[:valid?] = INSTRUMENTATION_CLASS_ERROR
|
|
42
|
+
q.validate ->(input) { input.match?(CLASS_NAME_REGEX) }
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def prompt_for_time_frame
|
|
47
|
+
metric.time_frame = cli.multi_select(
|
|
48
|
+
'For which time frames do you want the metric to be calculated? (Space to select)',
|
|
49
|
+
time_frame_options,
|
|
50
|
+
**multiselect_opts,
|
|
51
|
+
**filter_opts(header_size: 7)
|
|
52
|
+
)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def time_frame_options
|
|
56
|
+
[
|
|
57
|
+
{
|
|
58
|
+
name: 'Weekly',
|
|
59
|
+
value: '7d'
|
|
60
|
+
}, {
|
|
61
|
+
name: 'Monthly',
|
|
62
|
+
value: '28d'
|
|
63
|
+
}, {
|
|
64
|
+
name: 'Total (no time restriction)',
|
|
65
|
+
value: 'all'
|
|
66
|
+
}
|
|
67
|
+
]
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|