pencil 0.2.0 → 0.2.2

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.
data/bin/pencil CHANGED
@@ -3,4 +3,4 @@ $: << File.dirname($0) + "/../lib"
3
3
 
4
4
  require "dash"
5
5
 
6
- Rack::Handler::WEBrick.run(Dash::App, :Port => Dash::App.port)
6
+ Rack::Handler::Mongrel.run(Dash::App, :Port => Dash::App.port)
@@ -0,0 +1,176 @@
1
+ # Pencil Options
2
+ Pencil configuration files are written in YAML. When pencil starts up, it
3
+ searches for these files and loads them. See the main README.md for how
4
+ these files should look.
5
+
6
+ ## General Configuration
7
+
8
+ These are options that go under the :config key in pencil configuration files.
9
+
10
+ * :graphite_url [String, required, no default]
11
+
12
+ The url of your graphite instance.
13
+
14
+ * :url_opts [Hash, required, no default]
15
+
16
+ A map of default graph options.
17
+
18
+ In addition to <a href="#gopts">graph-level options</a>, an important default option
19
+ you should set under :url_opts is
20
+
21
+ :start: TIMESPEC
22
+
23
+ TIMESPEC should be a
24
+ [chronic](http://chronic.rubyforge.org/)-parsable time, and to be useful
25
+ should be relative to the current time (e.g. "8 hours ago")
26
+
27
+ * :refresh_rate [Fixnum, optional, default 60]
28
+
29
+ How often to refresh a changing view, in seconds.
30
+
31
+ This doesn't apply to timeslices that aren't varying (i.e not current, see
32
+ <a href="#threshold">:now_threshold</a>).
33
+
34
+ Set this to false to disable automatic refreshing.
35
+
36
+ * :host_sort [["builtin", "numeric", "sensible"], optional, default "sensible"]
37
+
38
+ Set to "builtin" to sort using ruby's builtin String sort.
39
+
40
+ Set to "numeric" to sort hosts numerically (i.e. match secondarily on the
41
+ first \d+).
42
+
43
+ Set to "sensible" if you want to sort like this:
44
+
45
+ http://www.bofh.org.uk/2007/12/16/comprehensible-sorting-in-ruby
46
+
47
+ * :quantum [Fixnum, optional, no default value]
48
+
49
+ Map requests to NUM second intervals. Pencil floors request times to the
50
+ minute, and does some modular arithmetic to do this mapping. This is
51
+ especially useful for implementing a caching layer, so that many requests
52
+ coming in near-simultaneously won't require graphite to generate different
53
+ images for each request.
54
+
55
+ Adding &noq=1 to a pencil url will disable this in case you need
56
+ super-granularity for some reason, but you didn't hear it from me.
57
+
58
+ * :date_format [String, optional, default "%X %x"]
59
+
60
+ strftime format for displaying dates.
61
+
62
+ * :metric_format [String, optional, default "%m.%c.%h"]
63
+
64
+ The format your graphite metrics are stored in. For pencil to work your
65
+ metrics need to be composed of three distinct pieces, concatenated in some
66
+ regular fashion. The format strings are
67
+
68
+ * %m metric
69
+ * %c cluster
70
+ * %h host
71
+
72
+ If you want a literal %[mch] in your metric format string you likely have
73
+ bigger problems than not being able to do so.
74
+
75
+ * <a name="threshold"/> :now\_threshold: [Fixnum, optional, default 300]
76
+
77
+ How many seconds before Time.now an end time is considered to still be 'now',
78
+ for the purposes of adding meta-refresh and displaying time intervals.
79
+
80
+ ## <a name="gopts"/> Graph-level Options
81
+ This is a list of the supported graph-level options for pencil, which
82
+ correspond to request(image)-level options for graphite. These options are
83
+ key-value pairs, and are passed directly to graphite. Here is the list, with
84
+ minor annotations:
85
+
86
+ * vtitle: String (y-axis label)
87
+ * yMin: Fixnum
88
+ * yMax: Fixnum
89
+ * lineWidth: Fixnum (line thickness in pixels)
90
+ * areaMode: \[first, all, stacked\] (see graphite documentation)
91
+ * template: \[noc, alphas\] (alphas inverts colors)
92
+ * lineMode: staircase
93
+ * bgcolor: String
94
+ * graphOnly: bool (hide legend, axes, grid)
95
+ * hideAxes: bool
96
+ * hideGrid: bool
97
+ * hideLegend: bool
98
+ * fgcolor: String
99
+ * fontSize: Fixnum
100
+ * fontName: String (see your graphite instance for available fonts)
101
+ * fontItalic: bool
102
+ * fontBold: bool
103
+
104
+ ## Target-level Options
105
+ This is a list of the supported target-level options for pencil. These are
106
+ mosly a list of transformations graphite supports, including summation and
107
+ scaling of metrics. You can apply them to individual metrics, or lists of
108
+ metrics. See the example configs for how this works. Also see the graphite
109
+ composer for the effects of these options, many of which are untested.
110
+
111
+ ### Combinations
112
+ These functions take an arbitrary number of targets (usually simple metrics)
113
+ for arguments.
114
+
115
+ * sumSeries
116
+ * averageSeries
117
+ * minSeries
118
+ * maxSeries
119
+ * group
120
+
121
+ ### Transformations
122
+ Some of these options take a single argument.
123
+
124
+ * scale
125
+ * offset
126
+ * derivative
127
+ * integral
128
+ * nonNegativeDerivative
129
+ * log BASE
130
+ * timeShift
131
+ * summarize
132
+ * hitcount
133
+
134
+ ### Calculations
135
+ These functions take an arbitrary number of targets (usually simple metrics)
136
+ for arguments.
137
+
138
+ * movingAverage
139
+ * stdev
140
+ * asPercent
141
+ * diffSeries
142
+ * ratio
143
+
144
+ ### Filters
145
+ Most of these options take a single argument.
146
+
147
+ * highestCurrent
148
+ * lowestCurrent
149
+ * nPercentile
150
+ * currentAbove
151
+ * currentBelow
152
+ * highestAverage
153
+ * lowestAverage
154
+ * averageAbove
155
+ * averageBelow
156
+ * maximumAbove
157
+ * maximumBelow
158
+ * sortByMaxima
159
+ * minimalist
160
+ * limit
161
+ * exclude
162
+
163
+ ### Special Operations
164
+ * alias
165
+ * key (alias for alias)
166
+ * cumulative
167
+ * drawAsInfinite
168
+ * lineWidth
169
+ * dashed
170
+ * keepLastValue
171
+ * substr
172
+ * threshold
173
+ * color
174
+
175
+ Note: key and color are interpreted differently from the other options, which
176
+ are more simply translated.
data/lib/config.rb ADDED
@@ -0,0 +1,103 @@
1
+ require "rubygems"
2
+ require "models"
3
+
4
+ module Dash
5
+ class Config
6
+ include Dash::Models
7
+
8
+ attr_reader :dashboards
9
+ attr_reader :graphs
10
+ attr_reader :hosts
11
+ attr_reader :clusters
12
+ attr_reader :global_config
13
+
14
+ def initialize
15
+ port = 9292
16
+ @rawconfig = {}
17
+ @confdir = "."
18
+
19
+ optparse = OptionParser.new do |o|
20
+ o.on("-d", "--config-dir DIR",
21
+ "location of the config directory (default .)") do |arg|
22
+ @confdir = arg
23
+ end
24
+ o.on("-p", "--port PORT", "port to bind to (default 9292)") do |arg|
25
+ port = arg.to_i
26
+ end
27
+ end
28
+
29
+ optparse.parse!
30
+ reload!
31
+ @global_config[:port] = port
32
+ end
33
+
34
+ def reload!
35
+ configs = Dir.glob("#{@confdir}/*.y{a,}ml")
36
+ configs.each { |c| @rawconfig.merge!(YAML.load(File.read(c))) }
37
+
38
+ [:graphs, :dashboards, :config].each do |c|
39
+ if not @rawconfig[c]
40
+ raise "Missing config name '#{c.to_s}'"
41
+ end
42
+ end
43
+
44
+ @global_config = @rawconfig[:config]
45
+ # do some sanity checking of other configuration parameters
46
+ [:graphite_url, :url_opts].each do |c|
47
+ if not @global_config[c]
48
+ raise "Missing config name '#{c.to_s}'"
49
+ end
50
+ end
51
+
52
+ # possibly check more url_opts here as well
53
+ if @global_config[:url_opts][:start]
54
+ if !ChronicDuration.parse(@global_config[:url_opts][:start])
55
+ raise "bad default timespec in :url_opts"
56
+ end
57
+ end
58
+
59
+ @global_config[:default_colors] ||=
60
+ ["blue", "green", "yellow", "red", "purple", "brown", "aqua", "gold"]
61
+
62
+ if @global_config[:refresh_rate]
63
+ duration = ChronicDuration.parse(@global_config[:refresh_rate].to_s)
64
+ if !duration
65
+ raise "couldn't parse key :refresh_rate"
66
+ end
67
+ @global_config[:refresh_rate] = duration
68
+ end
69
+
70
+ @global_config[:metric_format] ||= "%m.%c.%h"
71
+ if @global_config[:metric_format] !~ /%m/
72
+ raise "missing metric (%m) in :metric_format"
73
+ elsif @global_config[:metric_format] !~ /%c/
74
+ raise "missing cluster (%c) in :metric_format"
75
+ elsif @global_config[:metric_format] !~ /%h/
76
+ raise "missing host (%h) in :metric_format"
77
+ end
78
+
79
+ graphs_new = []
80
+ @rawconfig[:graphs].each do |name, config|
81
+ graphs_new << Graph.new(name, config.merge(@global_config))
82
+ end
83
+
84
+ dashboards_new = []
85
+ @rawconfig[:dashboards].each do |name, config|
86
+ dashboards_new << Dashboard.new(name, config.merge(@global_config))
87
+ end
88
+
89
+ hosts_new = Set.new
90
+ clusters_new = Set.new
91
+
92
+ # generate host and cluster information at init time
93
+ graphs_new.each do |g|
94
+ hosts, clusters = g.hosts_clusters
95
+ hosts.each { |h| hosts_new << h }
96
+ clusters.each { |h| clusters_new << h }
97
+ end
98
+
99
+ @dashboards, @graphs = dashboards_new, graphs_new
100
+ @hosts, @clusters = hosts_new, clusters_new
101
+ end
102
+ end # Dash::Config
103
+ end # Dash
data/lib/config.ru ADDED
@@ -0,0 +1,2 @@
1
+ require 'dash'
2
+ run Dash::App
data/lib/dash.rb ADDED
@@ -0,0 +1,153 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require "rubygems"
4
+ require "namespace"
5
+
6
+ require "config"
7
+ require "erb"
8
+ require "helpers"
9
+ require "models"
10
+ require "rack"
11
+ require "sinatra/base"
12
+ require "json"
13
+ require "open-uri"
14
+ require "yaml"
15
+ require "chronic"
16
+ require "chronic_duration"
17
+ require "optparse"
18
+
19
+ # fixme style.css isn't actually cached, you need to set something up with
20
+ # rack to cache static files
21
+
22
+ $:.unshift(File.dirname(__FILE__))
23
+
24
+ module Dash
25
+ class App < Sinatra::Base
26
+ include Dash::Models
27
+ helpers Dash::Helpers
28
+ config = Dash::Config.new
29
+ set :config, config
30
+ set :port, config.global_config[:port]
31
+ set :run, true
32
+ use Rack::Session::Cookie, :expire_after => 126227700 # 4 years
33
+ set :root, File.dirname(__FILE__)
34
+ set :static, true
35
+
36
+ def initialize(settings={})
37
+ super
38
+ end
39
+
40
+ before do
41
+ session[:not] #fixme kludge is back
42
+ @request_time = Time.now
43
+ @dashboards = Dashboard.all
44
+ @no_graphs = false
45
+ # time stuff
46
+ start = param_lookup("start")
47
+ duration = param_lookup("duration")
48
+ @stime = Chronic.parse(start)
49
+ if @stime
50
+ @stime -= @stime.sec unless @params["noq"]
51
+ end
52
+ if duration
53
+ @duration = ChronicDuration.parse(duration)
54
+ else
55
+ @duration = @request_time.to_i - @stime.to_i
56
+ end
57
+
58
+ unless @params["noq"]
59
+ @duration -= (@duration % settings.config.global_config[:quantum]||1)
60
+ end
61
+
62
+ if @stime
63
+ @etime = Time.at(@stime + @duration)
64
+ @etime = @request_time if @etime > @request_time
65
+ else
66
+ @etime = @request_time
67
+ end
68
+
69
+ params[:stime] = @stime.to_i.to_s
70
+ params[:etime] = @etime.to_i.to_s
71
+ # fixme reload hosts after some expiry
72
+ end
73
+
74
+ get %r[^/(dash/?)?$] do
75
+ @no_graphs = true
76
+ redirect '/dash/global'
77
+ end
78
+
79
+ get '/dash/:cluster/:dashboard/:zoom/?' do
80
+ @cluster = params[:cluster]
81
+ @dash = Dashboard.find(params[:dashboard])
82
+ raise "Unknown dashboard: #{params[:dashboard].inspect}" unless @dash
83
+
84
+ @zoom = nil
85
+ @dash.graphs.each do |graph|
86
+ @zoom = graph if graph.name == params[:zoom]
87
+ end
88
+ raise "Unknown zoom parameter: #{params[:zoom]}" unless @zoom
89
+
90
+ @title = "dashboard :: #{@cluster} :: #{@dash['title']} :: #{params[:zoom]}"
91
+
92
+ if @cluster == "global"
93
+ erb :'dash-global-zoom'
94
+ else
95
+ erb :'dash-cluster-zoom'
96
+ end
97
+ end
98
+
99
+ get '/dash/:cluster/:dashboard/?' do
100
+ @cluster = params[:cluster]
101
+ @dash = Dashboard.find(params[:dashboard])
102
+ raise "Unknown dashboard: #{params[:dashboard].inspect}" unless @dash
103
+
104
+ @title = "dashboard :: #{@cluster} :: #{@dash['title']}"
105
+
106
+ if @cluster == "global"
107
+ erb :'dash-global'
108
+ else
109
+ erb :'dash-cluster'
110
+ end
111
+ end
112
+
113
+ get '/dash/:cluster/?' do
114
+ @no_graphs = true
115
+ @cluster = params[:cluster]
116
+ if @cluster == "global"
117
+ @title = "Overview"
118
+ erb :global
119
+ else
120
+ @title = "cluster :: #{params[:cluster]}"
121
+ erb :cluster
122
+ end
123
+ end
124
+
125
+ get '/host/:cluster/:host/?' do
126
+ @host = Host.new(params[:host], { "cluster" => params[:cluster] })
127
+ @cluster = params[:cluster]
128
+ raise "Unknown host: #{params[:host]} in #{params[:cluster]}" unless @host
129
+
130
+ @title = "#{@host.cluster} :: host :: #{@host.name}"
131
+
132
+ erb :host
133
+ end
134
+
135
+ # fixme make sure not to save shitty values for :start, :duration
136
+ # remove stime, etime
137
+ # there is definitely something wrong here
138
+ # disallow saving from, until
139
+ get '/saveprefs' do
140
+ puts 'saving prefs'
141
+ params.each do |k,v|
142
+ session[k] = v unless v.empty?
143
+ end
144
+ redirect URI.parse(request.referrer).path
145
+ end
146
+
147
+ get '/clear' do
148
+ puts "clearing prefs"
149
+ session.clear
150
+ redirect URI.parse(request.referrer).path
151
+ end
152
+ end # Dash::App
153
+ end # Dash
data/lib/helpers.rb ADDED
@@ -0,0 +1,197 @@
1
+ module Dash::Helpers
2
+ include Dash::Models
3
+
4
+ @@prefs = [["Start", "start"],
5
+ ["Duration", "duration"],
6
+ ["Width", "width"],
7
+ ["Height", "height"]]
8
+
9
+ # convert keys to symbols before lookup
10
+ def param_lookup(name)
11
+ sym_hash = {}
12
+ session.each { |k,v| sym_hash[k.to_sym] = v unless v.empty? }
13
+ params.each { |k,v| sym_hash[k.to_sym] = v unless v.empty? }
14
+ settings.config.global_config[:url_opts].merge(sym_hash)[name.to_sym]
15
+ end
16
+
17
+ def cluster_graph(g, cluster, title="wtf")
18
+ image_url = \
19
+ @dash.render_cluster_graph(g, cluster,
20
+ :title => title,
21
+ :dynamic_url_opts => merge_opts)
22
+ zoom_url = cluster_graph_link(@dash, g, cluster)
23
+ return image_url, zoom_url
24
+ end
25
+
26
+ def cluster_graph_link(dash, g, cluster)
27
+ link = dash.graph_opts[g]["click"] ||
28
+ "/dash/#{cluster}/#{dash.name}/#{g.name}"
29
+ return append_query_string(link)
30
+ end
31
+
32
+ def cluster_zoom_graph(g, cluster, host, title)
33
+ image_url = g.render_url([host.name], [cluster], :title => title,
34
+ :dynamic_url_opts => merge_opts)
35
+ zoom_url = cluster_zoom_link(cluster, host)
36
+ return image_url, zoom_url
37
+ end
38
+
39
+ def cluster_zoom_link(cluster, host)
40
+ return append_query_string("/host/#{cluster}/#{host}")
41
+ end
42
+
43
+ def suggest_cluster_links(clusters, g)
44
+ links = []
45
+ clusters.each do |c|
46
+ href = append_query_string("/dash/#{c}/#{params[:dashboard]}/#{g.name}")
47
+ links << "<a href=\"#{href}\">#{c}</a>"
48
+ end
49
+ return "zoom (" + links.join(", ") + ")"
50
+ end
51
+
52
+ def suggest_dashboards_links(host, graph)
53
+ suggested = suggest_dashboards(host, graph)
54
+ return "" if suggested.length == 0
55
+
56
+ links = []
57
+ suggested.each do |d|
58
+ links << "<a href=\"/dash/#{host.cluster}/#{append_query_string(d)}\">" +
59
+ "#{d}</a>"
60
+ end
61
+ return "(" + links.join(", ") + ")"
62
+ end
63
+
64
+ # it's mildly annoying that when this set is empty there're no uplinks
65
+ # consider adding a link up to the cluster (which is best we can do)
66
+ def suggest_dashboards(host, graph)
67
+ ret = Set.new
68
+
69
+ host.graphs.each do |g|
70
+ Dashboard.find_by_graph(g).each do |d|
71
+ valid, _ = d.get_valid_hosts(g, host['cluster'])
72
+ ret << d.name if valid.member?(host)
73
+ end
74
+ end
75
+
76
+ return ret
77
+ end
78
+
79
+ # generate the input box fields, filled in to current parameters if specified
80
+ def input_boxes
81
+ @prefs = @@prefs
82
+ erb :'partials/input_boxes', :layout => false
83
+ end
84
+
85
+ def cookies_form
86
+ @prefs = @@prefs
87
+ erb :'partials/cookies_form', :layout => false
88
+ end
89
+
90
+ def refresh_button
91
+ @prefs = @@prefs
92
+ erb :'partials/refresh_button', :layout => false
93
+ end
94
+
95
+ def dash_link(dash, cluster)
96
+ return append_query_string("/dash/#{cluster}/#{dash.name}")
97
+ end
98
+
99
+ def cluster_link(cluster)
100
+ return append_query_string("/dash/#{cluster}")
101
+ end
102
+
103
+ def css_url
104
+ style = File.join(settings.root, "public/style.css")
105
+ mtime = File.mtime(style).to_i.to_s
106
+ return \
107
+ %Q[<link href="/style.css?#{mtime}" rel="stylesheet" type="text/css">]
108
+ end
109
+
110
+ def refresh
111
+ if settings.config.global_config[:refresh_rate] != false && nowish
112
+ rate = settings.config.global_config[:refresh_rate] || 60
113
+ return %Q[<meta http-equiv="refresh" content="#{rate}">]
114
+ end
115
+ end
116
+
117
+ def hosts_selector(hosts)
118
+ @hosts = hosts
119
+ erb :'partials/hosts_selector', :layout => false
120
+ end
121
+
122
+ def append_query_string(str)
123
+ v = str.dup
124
+ query = request.query_string.chomp("&permalink=1")
125
+ (v << "?#{query}") unless request.query_string.empty?
126
+ return v
127
+ end
128
+
129
+ def merge_opts
130
+ static_opts = ["cluster", "dashboard", "zoom", "host", "session_id"]
131
+ opts = params.dup
132
+ session.merge(opts).delete_if { |k,v| static_opts.member?(k) || v.empty? }
133
+ end
134
+
135
+ def cluster_switcher
136
+ erb :'partials/cluster_switcher', :layout => false
137
+ end
138
+
139
+ def dash_switcher
140
+ erb :'partials/dash_switcher', :layout => false
141
+ end
142
+
143
+ def graph_switcher
144
+ erb :'partials/graph_switcher', :layout => false
145
+ end
146
+
147
+ def cluster_selector
148
+ @clusters = settings.config.clusters.sort + ["global"]
149
+ erb :'partials/cluster_selector', :layout => false
150
+ end
151
+
152
+ def host_uplink
153
+ link = "/dash/#{append_query_string(@host.cluster)}"
154
+ "zoom out: <a href=\"#{link}\">#{@host.cluster}</a>"
155
+ end
156
+
157
+ def graph_uplink
158
+ link = append_query_string(request.path.split("/")[0..-2].join("/"))
159
+ "zoom out: <a href=\"#{link}\">#{@dash}</a>"
160
+ end
161
+
162
+ def dash_uplink
163
+ link = append_query_string(request.path.split("/")[0..-2].join("/"))
164
+ "zoom out: <a href=\"#{link}\">#{@params[:cluster]}</a>"
165
+ end
166
+
167
+ def nowish
168
+ if settings.config.global_config[:now_threshold] == false
169
+ return false
170
+ end
171
+ threshold = settings.config.global_config[:now_threshold] || 300
172
+ return @request_time.to_i - @etime.to_i < threshold
173
+ end
174
+
175
+ def range_string
176
+ format = settings.config.global_config[:date_format] || "%X %x"
177
+ if @stime && @etime
178
+ if nowish
179
+ "timeslice: from #{@stime.strftime(format)}"
180
+ else
181
+ "timeslice: #{@stime.strftime(format)} - #{@etime.strftime(format)}"
182
+ end
183
+ else
184
+ "invalid time range"
185
+ end
186
+
187
+ end
188
+
189
+ def permalink
190
+ return "" unless @stime && @duration
191
+ format = "%x %X" # chronic understands this
192
+ url = request.path + "?"
193
+ url << "&start=#{@stime.strftime(format)}"
194
+ url << "&duration=#{ChronicDuration.output(@duration)}"
195
+ "<a href=\"#{url}\">permalink</a>"
196
+ end
197
+ end
@@ -0,0 +1,73 @@
1
+ module Dash::Models
2
+ class Base
3
+ @@objects = Hash.new { |h, k| h[k] = Hash.new }
4
+ attr_reader :name
5
+
6
+ def initialize(name, params={})
7
+ @name = name
8
+ @match_name = name
9
+ @params = params
10
+ @@objects[self.class.to_s][name] = self
11
+ end
12
+
13
+ def self.find(name)
14
+ return @@objects[self.name][name] rescue []
15
+ end
16
+
17
+ def self.each(&block)
18
+ h = @@objects[self.name] rescue {}
19
+ h.each { |k, v| yield(k, v) }
20
+ end
21
+
22
+ def self.all
23
+ return @@objects[self.name].values
24
+ end
25
+
26
+ def [](key)
27
+ return @params[key] rescue []
28
+ end
29
+
30
+ def match(glob)
31
+ return true if glob == '*'
32
+ # convert glob to a regular expression
33
+ glob_parts = glob.split('*').collect { |s| Regexp.escape(s) }
34
+ if glob[0].chr == '*'
35
+ glob_re = /^.*#{glob_parts.join('.*')}$/
36
+ elsif glob[-1].chr == '*'
37
+ glob_re = /^#{glob_parts.join('.*')}.*$/
38
+ else
39
+ glob_re = /^#{glob_parts.join('.*')}$/
40
+ end
41
+ return @match_name.match(glob_re)
42
+ end
43
+
44
+ def multi_match(globs)
45
+ ret = false
46
+
47
+ globs.each do |glob|
48
+ ret = match(glob)
49
+ break if ret
50
+ end
51
+
52
+ return ret
53
+ end
54
+
55
+ def to_s
56
+ return @name
57
+ end
58
+
59
+ def <=>(other)
60
+ return to_s <=> other.to_s
61
+ end
62
+
63
+ def update_params(hash)
64
+ @params.merge!(hash)
65
+ end
66
+
67
+ # compose a metric using a :metric_format
68
+ # format string with %c for metric, %c for cluster, and %h for host
69
+ def compose_metric (m, c, h)
70
+ @params[:metric_format].dup.gsub("%m", m).gsub("%c", c).gsub("%h", h)
71
+ end
72
+ end # Dash::Models::Base
73
+ end # Dash::Models