pencil 0.2.0 → 0.2.2

Sign up to get free protection for your applications and to get access to all the features.
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