ruby-grafana-reporter 0.1.6

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 (45) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +20 -0
  3. data/README.md +248 -0
  4. data/lib/VERSION.rb +3 -0
  5. data/lib/grafana/abstract_panel_query.rb +20 -0
  6. data/lib/grafana/abstract_query.rb +127 -0
  7. data/lib/grafana/abstract_sql_query.rb +42 -0
  8. data/lib/grafana/dashboard.rb +66 -0
  9. data/lib/grafana/errors.rb +61 -0
  10. data/lib/grafana/grafana.rb +131 -0
  11. data/lib/grafana/panel.rb +39 -0
  12. data/lib/grafana/panel_image_query.rb +49 -0
  13. data/lib/grafana/variable.rb +259 -0
  14. data/lib/grafana_reporter/abstract_report.rb +109 -0
  15. data/lib/grafana_reporter/application/application.rb +229 -0
  16. data/lib/grafana_reporter/application/errors.rb +30 -0
  17. data/lib/grafana_reporter/asciidoctor/alerts_table_query.rb +99 -0
  18. data/lib/grafana_reporter/asciidoctor/annotations_table_query.rb +96 -0
  19. data/lib/grafana_reporter/asciidoctor/errors.rb +37 -0
  20. data/lib/grafana_reporter/asciidoctor/extensions/alerts_table_include_processor.rb +86 -0
  21. data/lib/grafana_reporter/asciidoctor/extensions/annotations_table_include_processor.rb +86 -0
  22. data/lib/grafana_reporter/asciidoctor/extensions/panel_image_block_macro.rb +67 -0
  23. data/lib/grafana_reporter/asciidoctor/extensions/panel_image_inline_macro.rb +65 -0
  24. data/lib/grafana_reporter/asciidoctor/extensions/panel_property_inline_macro.rb +58 -0
  25. data/lib/grafana_reporter/asciidoctor/extensions/panel_query_table_include_processor.rb +75 -0
  26. data/lib/grafana_reporter/asciidoctor/extensions/panel_query_value_inline_macro.rb +70 -0
  27. data/lib/grafana_reporter/asciidoctor/extensions/processor_mixin.rb +18 -0
  28. data/lib/grafana_reporter/asciidoctor/extensions/show_environment_include_processor.rb +41 -0
  29. data/lib/grafana_reporter/asciidoctor/extensions/show_help_include_processor.rb +202 -0
  30. data/lib/grafana_reporter/asciidoctor/extensions/sql_table_include_processor.rb +67 -0
  31. data/lib/grafana_reporter/asciidoctor/extensions/sql_value_inline_macro.rb +65 -0
  32. data/lib/grafana_reporter/asciidoctor/extensions/value_as_variable_include_processor.rb +57 -0
  33. data/lib/grafana_reporter/asciidoctor/panel_first_value_query.rb +32 -0
  34. data/lib/grafana_reporter/asciidoctor/panel_image_query.rb +23 -0
  35. data/lib/grafana_reporter/asciidoctor/panel_property_query.rb +43 -0
  36. data/lib/grafana_reporter/asciidoctor/panel_table_query.rb +36 -0
  37. data/lib/grafana_reporter/asciidoctor/query_mixin.rb +309 -0
  38. data/lib/grafana_reporter/asciidoctor/report.rb +159 -0
  39. data/lib/grafana_reporter/asciidoctor/sql_first_value_query.rb +34 -0
  40. data/lib/grafana_reporter/asciidoctor/sql_table_query.rb +32 -0
  41. data/lib/grafana_reporter/configuration.rb +326 -0
  42. data/lib/grafana_reporter/errors.rb +38 -0
  43. data/lib/grafana_reporter/logger/two_way_logger.rb +52 -0
  44. data/lib/ruby-grafana-reporter.rb +27 -0
  45. metadata +88 -0
@@ -0,0 +1,66 @@
1
+ module Grafana
2
+ # Representation of one specific dashboard in a {Grafana} instance.
3
+ class Dashboard
4
+ # @return [Grafana] parent {Grafana} object
5
+ attr_reader :grafana
6
+ attr_reader :panels, :variables
7
+
8
+ # @param model [Hash] converted JSON Hash of the grafana dashboard
9
+ # @param grafana [Grafana] parent {Grafana} object
10
+ def initialize(model, grafana)
11
+ @grafana = grafana
12
+ @model = model
13
+
14
+ # read panels
15
+ @panels = []
16
+ if @model.key?('panels')
17
+ @model['panels'].each do |panel|
18
+ if panel.key?('panels')
19
+ panel['panels'].each do |subpanel|
20
+ @panels << Panel.new(subpanel, self)
21
+ end
22
+ else
23
+ @panels << Panel.new(panel, self)
24
+ end
25
+ end
26
+ end
27
+
28
+ # store variables in array as objects of type Variable
29
+ @variables = []
30
+ return unless @model.key?('templating')
31
+
32
+ list = @model['templating']['list']
33
+ return unless list.is_a? Array
34
+
35
+ list.each do |item|
36
+ @variables << Variable.new(item)
37
+ end
38
+ end
39
+
40
+ # @return [String] +from+ time configured in the dashboard.
41
+ def from_time
42
+ return @model['time']['from'] if @model['time']
43
+
44
+ nil
45
+ end
46
+
47
+ # @return [String] +to+ time configured in the dashboard.
48
+ def to_time
49
+ @model['time']['to'] if @model['time']
50
+ nil
51
+ end
52
+
53
+ # @return [String] dashboard UID
54
+ def id
55
+ @model['uid']
56
+ end
57
+
58
+ # @return [Panel] panel for the specified ID
59
+ def panel(id)
60
+ panels = @panels.select { |item| item.field('id') == id.to_i }
61
+ raise PanelDoesNotExistError.new(id, self) if panels.empty?
62
+
63
+ panels.first
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,61 @@
1
+ module Grafana
2
+ # A top level alarm for all other errors in current module.
3
+ class GrafanaError < StandardError
4
+ def initialize(message)
5
+ super("GrafanaError: #{message} (#{self.class})")
6
+ end
7
+ end
8
+
9
+ # Raised if a given dashboard does not exist in a specific {Grafana} instance.
10
+ class DashboardDoesNotExistError < GrafanaError
11
+ # @param dashboard_uid [String] dashboard uid, which could not be found
12
+ def initialize(dashboard_uid)
13
+ super("The specified dashboard '#{dashboard_uid}' does not exist.")
14
+ end
15
+ end
16
+
17
+ # Raised if a given panel does not exist on a specific {Dashboard} in the current {Grafana} instance.
18
+ class PanelDoesNotExistError < GrafanaError
19
+ # @param panel_id [String] panel id, which could not be found on the dashboard
20
+ # @param dashboard [Dashboard] dashboard object on which the panel could not be found
21
+ def initialize(panel_id, dashboard)
22
+ super("The specified panel id '#{panel_id}' does not exist on the dashboard '#{dashboard.id}'.")
23
+ end
24
+ end
25
+
26
+ # Raised if a given query letter does not exist on a specific {Panel}.
27
+ class QueryLetterDoesNotExistError < GrafanaError
28
+ # @param query_letter [String] query letter name, which could not be found on the panel
29
+ # @param panel [Panel] panel object on which the query could not be found
30
+ def initialize(query_letter, panel)
31
+ super("The specified query '#{query_letter}' does not exist in the panel '#{panel.id}' in dashboard '#{panel.dashboard}'.")
32
+ end
33
+ end
34
+
35
+ # Raised if a given datasource does not exist in a specific {Grafana} instance.
36
+ class DatasourceDoesNotExistError < GrafanaError
37
+ # @param field [String] specifies, how the datasource has been searched, e.g. 'id' or 'name'
38
+ # @param datasource_identifier [String] identifier of the datasource, which could not be found, e.g. the specifiy id or name
39
+ def initialize(field, datasource_identifier)
40
+ super("Datasource with #{field} '#{datasource_identifier}' does not exist.")
41
+ end
42
+ end
43
+
44
+ # Raised if a {Panel} could not be rendered as an image.
45
+ #
46
+ # Most likely this happens, because the image renderer is not configures properly in grafana,
47
+ # or the panel rendering ran into a timeout.
48
+ class ImageCouldNotBeRenderedError < GrafanaError
49
+ # @param panel [Panel] panel object, which could not be rendered
50
+ def initialize(panel)
51
+ super("The specified panel '#{panel.id}' from dashboard '#{panel.dashboard.id} could not be rendered to an image.")
52
+ end
53
+ end
54
+
55
+ # Raised if no SQL query is specified in a {AbstractSqlQuery} object.
56
+ class MissingSqlQueryError < GrafanaError
57
+ def initialize
58
+ super('No SQL statement has been specified.')
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,131 @@
1
+ # Contains all objects for creating structured objects for interfacing grafana.
2
+ #
3
+ # The intention is, that these represent the business logic contained within grafana in an appropriate object model for the reporter to work with.
4
+ #
5
+ # For details, see also {https://grafana.com/docs/grafana/latest/http_api Grafana API}.
6
+ module Grafana
7
+ # Main class for handling the interaction with one specific Grafana instance.
8
+ class Grafana
9
+ # @param base_uri [String] full URI pointing to the specific grafana instance without trailing slash, e.g. +https://localhost:3000+.
10
+ # @param key [String] API key for the grafana instance, if required
11
+ # @param opts [Hash] additional options.
12
+ # Currently supporting +:logger+ and +:datasources+.
13
+ # +:datasources+ need to be an Hash with datasource name as key and datasource id as value.
14
+ # If not specified, the datasources will be queried from the grafana interface.
15
+ # Specifying +:datasources+> here can be used, so that the interface can be used without grafana Admin privileges.
16
+ def initialize(base_uri, key = nil, opts = {})
17
+ @base_uri = base_uri
18
+ @key = key
19
+ @dashboards = {}
20
+ @logger = opts[:logger] || ::Logger.new(nil)
21
+ @datasources = opts[:datasources]
22
+ end
23
+
24
+ # Used to test a connection to the grafana instance.
25
+ #
26
+ # Running this function also determines, if the API configured here has Admin or NON-Admin privileges,
27
+ # or even fails on connecting to grafana.
28
+ #
29
+ # @return [String] +Admin+, +NON-Admin+ or +Failed+ is returned, depending on the test results
30
+ def test_connection
31
+ res = execute_http_request('/api/datasources')
32
+ if res.is_a?(Net::HTTPOK)
33
+ # we have admin rights
34
+ @logger.info('Reporter is running with Admin privileges on grafana.')
35
+ return 'Admin'
36
+ else
37
+ # check if we have lower rights
38
+ res = execute_http_request('/api/dashboards/home')
39
+ if res.is_a?(Net::HTTPOK)
40
+ @logger.warn('Reporter is running with NON-Admin privileges on grafana. Make sure that necessary datasources are specified in CONFIG_FILE, otherwise operation will fail')
41
+ return 'NON-Admin'
42
+ end
43
+ end
44
+
45
+ 'Failed'
46
+ end
47
+
48
+ # Returns the ID of a datasource, which has been queried by the datasource name.
49
+ #
50
+ # @return [Integer] ID for the specified datasource name
51
+ def datasource_id(datasource_name)
52
+ id = datasources[datasource_name]
53
+ raise DatasourceDoesNotExistError.new('name', datasource_name) unless id
54
+
55
+ id
56
+ end
57
+
58
+ # Returns if the given datasource ID exists for the grafana instance.
59
+ #
60
+ # @return [Boolean] true if exists, false otherwise
61
+ def datasource_id_exists?(datasource_id)
62
+ datasources.value?(datasource_id)
63
+ end
64
+
65
+ # @param dashboard_uid [String] UID of the searched {Dashboard}
66
+ # @return [Dashboard] dashboard object, if it has been found
67
+ def dashboard(dashboard_uid)
68
+ return @dashboards[dashboard_uid] unless @dashboards[dashboard_uid].nil?
69
+
70
+ response = execute_http_request('/api/dashboards/uid/' + dashboard_uid)
71
+ model = JSON.parse(response.body)['dashboard']
72
+
73
+ raise DashboardDoesNotExistError, dashboard_uid if model.nil?
74
+
75
+ # cache dashboard for reuse
76
+ @dashboards[dashboard_uid] = Dashboard.new(model, self)
77
+
78
+ @dashboards[dashboard_uid]
79
+ end
80
+
81
+ # Runs a specific HTTP request against the current grafana instance.
82
+ #
83
+ # Default (can be overridden, by specifying the options Hash):
84
+ # accept: 'application/json'
85
+ # request: Net::HTTP::Get
86
+ # content_type: 'application/json'
87
+ #
88
+ # @param relative_uri [String] relative URL with a leading slash, which shall be queried
89
+ # @param options [Hash] options, which shall be merged to the request.
90
+ # @param timeout [Integer] number of seconds to wait, before the http request is cancelled, defaults to 60 seconds
91
+ def execute_http_request(relative_uri, options = {}, timeout = nil)
92
+ uri = URI.parse(@base_uri + relative_uri)
93
+ default_options = { accept: 'application/json', request: Net::HTTP::Get, content_type: 'application/json' }
94
+ options = default_options.merge(options)
95
+
96
+ http = Net::HTTP.new(uri.host, uri.port)
97
+ if @base_uri =~ /^https/
98
+ http.use_ssl = true
99
+ http.verify_mode = OpenSSL::SSL::VERIFY_PEER
100
+ end
101
+ http.read_timeout = timeout.to_i if timeout
102
+
103
+ request = options[:request].new(uri.request_uri)
104
+ request['Accept'] = options[:accept]
105
+ request['Content-Type'] = options[:content_type]
106
+ request['Authorization'] = 'Bearer ' + @key unless @key.nil?
107
+ request.body = options[:body]
108
+
109
+ @logger.debug("Requesting #{relative_uri} with '#{options[:body]}' and timeout '#{http.read_timeout}'")
110
+ resp = http.request(request)
111
+ resp
112
+ end
113
+
114
+ private
115
+
116
+ def datasources
117
+ if @datasources.nil?
118
+ # load datasources from grafana directly, if allowed
119
+ response = execute_http_request('/api/datasources')
120
+ if response['message'].nil?
121
+ json = JSON.parse(response.body)
122
+ # only store needed values
123
+ @datasources = json.map { |item| [item['name'], item['id']] }.to_h
124
+ else
125
+ @datasources = {}
126
+ end
127
+ end
128
+ @datasources
129
+ end
130
+ end
131
+ end
@@ -0,0 +1,39 @@
1
+ module Grafana
2
+ # Representation of one specific panel in a {Dashboard} instance.
3
+ class Panel
4
+ # @return [Dashboard] parent {Dashboard} object
5
+ attr_reader :dashboard
6
+
7
+ # @param model [Hash] converted JSON Hash of the panel
8
+ # @param dashboard [Dashboard] parent {Dashboard} object
9
+ def initialize(model, dashboard)
10
+ @model = model
11
+ @dashboard = dashboard
12
+ end
13
+
14
+ # @return [String] content of the requested field or +''+ if not found
15
+ def field(field)
16
+ return @model[field] if @model.key?(field)
17
+
18
+ ''
19
+ end
20
+
21
+ # @return [String] panel ID
22
+ def id
23
+ @model['id']
24
+ end
25
+
26
+ # @return [String] SQL query string for the requested query letter
27
+ def query(query_letter)
28
+ query_item = @model['targets'].select { |item| item['refId'].to_s == query_letter.to_s }.first
29
+ raise QueryLetterDoesNotExistError.new(query_letter, self) if query_item.nil?
30
+
31
+ query_item['rawSql']
32
+ end
33
+
34
+ # @return [String] relative rendering URL for the panel, to create an image out of it
35
+ def render_url
36
+ "/render/d-solo/#{@dashboard.id}?panelId=#{@model['id']}"
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,49 @@
1
+ module Grafana
2
+ # Query, which allows to render a {Panel} as a PNG image.
3
+ class PanelImageQuery < AbstractPanelQuery
4
+ # Returns the URL for rendering the panel. Uses {Panel#render_url} and sets additional url parameters according {https://grafana.com/docs/grafana/latest/reference/share_panel Grafana Share Panel}.
5
+ #
6
+ # @see AbstractQuery#url
7
+ # @return [String] string for rendering the panel
8
+ def url
9
+ @panel.render_url + url_parameters
10
+ end
11
+
12
+ # Changes the result of the request to be of type +image/png+.
13
+ #
14
+ # @see AbstractQuery#request
15
+ def request
16
+ { accept: 'image/png' }
17
+ end
18
+
19
+ # Adds default variables for querying the image.
20
+ #
21
+ # @see AbstractQuery#pre_process
22
+ def pre_process(_grafana)
23
+ @variables['fullscreen'] = Variable.new(true)
24
+ @variables['theme'] = Variable.new('light')
25
+ @variables['timeout'] = Variable.new(timeout) if timeout
26
+ @variables['timeout'] ||= Variable.new(60)
27
+ end
28
+
29
+ # Checks if the rendering has been performed properly.
30
+ # If so, the resulting image is stored in the @result variable, otherwise an error is raised.
31
+ #
32
+ # @see AbstractQuery#post_process
33
+ def post_process
34
+ raise ImageCouldNotBeRenderedError, @panel if @result.body.include?('<html')
35
+ end
36
+
37
+ private
38
+
39
+ def url_parameters
40
+ url_vars = variables.select { |k, _v| k =~ /^(?:timeout|height|width|theme|fullscreen)/ || k =~ /^var-.+/ }
41
+ url_vars['from'] = Variable.new(@from) if @from
42
+ url_vars['to'] = Variable.new(@to) if @to
43
+ url_params = URI.encode_www_form(url_vars.map { |k, v| [k, v.raw_value.to_s] })
44
+ return '' if url_params.empty?
45
+
46
+ '&' + url_params
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,259 @@
1
+ module Grafana
2
+ # This class contains a representation of
3
+ # {https://grafana.com/docs/grafana/latest/variables/templates-and-variables grafana variables},
4
+ # aka grafana templates.
5
+ #
6
+ # The main need therefore rises in order to replace variables properly in different
7
+ # texts, e.g. SQL statements or results.
8
+ class Variable
9
+ attr_reader :name, :text, :raw_value
10
+
11
+ # @param config_or_value [Hash, Object] configuration hash of a variable out of an {Dashboard} instance or a value of any kind.
12
+ def initialize(config_or_value)
13
+ if config_or_value.is_a? Hash
14
+ @config = config_or_value
15
+ @name = @config['name']
16
+ unless @config['current'].nil?
17
+ @raw_value = @config['current']['value']
18
+ @text = @config['current']['text']
19
+ end
20
+ else
21
+ @config = {}
22
+ @raw_value = config_or_value
23
+ @text = config_or_value.to_s
24
+ end
25
+ end
26
+
27
+ # Returns the stored value formatted according the given format.
28
+ #
29
+ # Supported formats are: +csv+, +distributed+, +doublequote+, +json+, +percentencode+, +pipe+, +raw+, +regex+, +singlequote+, +sqlstring+, +lucene+, +date+ or +glob+ (default)
30
+ #
31
+ # For details see {https://grafana.com/docs/grafana/latest/variables/advanced-variable-format-options Grafana Advanced variable format options}.
32
+ #
33
+ # For details of +date+ format, see {https://grafana.com/docs/grafana/latest/variables/variable-types/global-variables/#__from-and-__to}. Please note that input for +date+ format is unixtime in milliseconds.
34
+ #
35
+ # @param format [String] desired format
36
+ # @return [String] value of stored variable according the specified format
37
+ def value_formatted(format = '')
38
+ value = @raw_value
39
+
40
+ # handle value 'All' properly
41
+ # TODO fix check for selection of All properly
42
+ if value == 'All' or @text == 'All'
43
+ if !@config['options'].empty?
44
+ value = @config['options'].map { |item| item['value'] }
45
+ elsif !@config['query'].empty?
46
+ # TODO: replace variables in this query, too
47
+ return @config['query']
48
+ # TODO handle 'All' value properly for query attributes
49
+ else
50
+ # TODO how to handle All selection properly at this point?
51
+ end
52
+ end
53
+
54
+ case format
55
+ when 'csv'
56
+ return value.join(',').to_s if multi?
57
+
58
+ value.to_s
59
+
60
+ when 'distributed'
61
+ return value.join(",#{name}=") if multi?
62
+
63
+ value
64
+ when 'doublequote'
65
+ if multi?
66
+ value = value.map { |item| "\"#{item.gsub(/[\\]/, '\\\\').gsub(/"/, '\\"')}\"" }
67
+ return value.join(',')
68
+ end
69
+ "\"#{value.gsub(/"/, '\\"')}\""
70
+
71
+ when 'json'
72
+ if multi?
73
+ value = value.map { |item| "\"#{item.gsub(/["\\]/, '\\\\' + '\0')}\"" }
74
+ return "[#{value.join(',')}]"
75
+ end
76
+ "\"#{value.gsub(/"/, '\\"')}\""
77
+
78
+ when 'percentencode'
79
+ value = "{#{value.join(',')}}" if multi?
80
+ ERB::Util.url_encode(value)
81
+
82
+ when 'pipe'
83
+ return value.join('|') if multi?
84
+
85
+ value
86
+
87
+ when 'raw'
88
+ return "{#{value.join(',')}}" if multi?
89
+
90
+ value
91
+
92
+ when 'regex'
93
+ if multi?
94
+ value = value.map { |item| item.gsub(%r{[/$\.\|\\]}, '\\\\' + '\0') }
95
+ return "(#{value.join('|')})"
96
+ end
97
+ value.gsub(%r{[/$\.\|\\]}, '\\\\' + '\0')
98
+
99
+ when 'singlequote'
100
+ if multi?
101
+ value = value.map { |item| "'#{item.gsub(/[']/, '\\\\' + '\0')}'" }
102
+ return value.join(',')
103
+ end
104
+ "'#{value.gsub(/[']/, '\\\\' + '\0')}'"
105
+
106
+ when 'sqlstring'
107
+ if multi?
108
+ value = value.map { |item| "'#{item.gsub(/'/, "''")}'" }
109
+ return value.join(',')
110
+ end
111
+ "'#{value.gsub(/'/, "''")}'"
112
+
113
+ when 'lucene'
114
+ if multi?
115
+ value = value.map { |item| "\"#{item.gsub(%r{[" |=/\\]}, '\\\\' + '\0')}\"" }
116
+ return "(#{value.join(' OR ')})"
117
+ end
118
+ value.gsub(%r{[" |=/\\]}, '\\\\' + '\0')
119
+
120
+ when /^date(?:[:](?<format>.*))?$/
121
+ #TODO validate how grafana handles multivariables with date format
122
+ get_date_formatted(value, $1)
123
+
124
+ when ''
125
+ # default
126
+ if multi?
127
+ value = value.map { |item| "'#{item.gsub(/'/, "''")}'" }
128
+ return value.join(',')
129
+ end
130
+ value.gsub(/'/, "''")
131
+
132
+ else
133
+ # glob and all unknown
134
+ #TODO add check for array value properly for all cases
135
+ return "{#{value.join(',')}}" if multi? and value.is_a?(Array)
136
+
137
+ value
138
+ end
139
+ end
140
+
141
+ # @return [Boolean] true, if the value can contain multiple selections, i.e. is an Array
142
+ def multi?
143
+ return @config['multi'] unless @config['multi'].nil?
144
+
145
+ @raw_value.is_a? Array
146
+ end
147
+
148
+ # @return [Object] raw value of the variable
149
+ def raw_value=(new_val)
150
+ @raw_value = new_val
151
+ @raw_value = @raw_value.to_s unless @raw_value.is_a?(Array)
152
+ new_text = @raw_value
153
+ if @config['options']
154
+ val = @config['options'].select { |item| item['value'] == @raw_value }
155
+ new_text = val.first['text'] unless val.empty?
156
+ end
157
+ @text = new_text
158
+ end
159
+
160
+ private
161
+
162
+ # Realize time formatting according
163
+ # {https://grafana.com/docs/grafana/latest/variables/variable-types/global-variables/#__from-and-__to}
164
+ # and {https://momentjs.com/docs/#/displaying/}.
165
+ def get_date_formatted(value, format)
166
+ return (Float(value) / 1000).to_i.to_s if format == 'seconds'
167
+ return Time.at((Float(value) / 1000).to_i).utc.iso8601(3) if !format or format == 'iso'
168
+
169
+ # build array of known matches
170
+ matches = []
171
+ work_string = format
172
+ while work_string.length > 0
173
+ tmp = work_string.scan(/^(?:M{1,4}|D{1,4}|d{1,4}|e|E|w{1,2}|W{1,2}|Y{4}|Y{2}|A|a|H{1,2}|h{1,2}|k{1,2}|m{1,2}|s{1,2}|S+|X)/)
174
+ unless tmp.empty?
175
+ matches << tmp[0]
176
+ work_string.delete_prefix!(tmp[0])
177
+ else
178
+ matches << work_string[0]
179
+ work_string.delete_prefix!(work_string[0])
180
+ end
181
+ end
182
+
183
+ #TODO move case when to hash
184
+ format_string = ""
185
+ matches.each do |match|
186
+ format_string += case match
187
+ when 'M'
188
+ '%-m'
189
+ when 'MM'
190
+ '%m'
191
+ when 'MMM'
192
+ '%b'
193
+ when 'MMMM'
194
+ '%B'
195
+ when 'D'
196
+ '%-d'
197
+ when 'DD'
198
+ '%d'
199
+ when 'DDD'
200
+ '%-j'
201
+ when 'DDDD'
202
+ '%j'
203
+ when 'YY'
204
+ '%y'
205
+ when 'YYYY'
206
+ '%Y'
207
+ when 'd'
208
+ '%w'
209
+ when 'ddd'
210
+ '%a'
211
+ when 'dddd'
212
+ '%A'
213
+ when 'e'
214
+ '%w'
215
+ when 'E'
216
+ '%u'
217
+ when 'w'
218
+ '%-U'
219
+ when 'ww'
220
+ '%U'
221
+ when 'W'
222
+ '%-V'
223
+ when 'WW'
224
+ '%V'
225
+ when 'YY'
226
+ '%y'
227
+ when 'YYYY'
228
+ '%Y'
229
+ when 'A'
230
+ '%p'
231
+ when 'a'
232
+ '%P'
233
+ when 'H'
234
+ '%-H'
235
+ when 'HH'
236
+ '%H'
237
+ when 'h'
238
+ '%-I'
239
+ when 'hh'
240
+ '%I'
241
+ when 'm'
242
+ '%-M'
243
+ when 'mm'
244
+ '%M'
245
+ when 's'
246
+ '%-S'
247
+ when 'ss'
248
+ '%S'
249
+ when 'X'
250
+ '%s'
251
+ else
252
+ match
253
+ end
254
+ end
255
+
256
+ Time.at((Float(value) / 1000).to_i).strftime(format_string)
257
+ end
258
+ end
259
+ end