mouth 0.8.0
Sign up to get free protection for your applications and to get access to all the features.
- data/.gitignore +7 -0
- data/Capfile +26 -0
- data/Gemfile +3 -0
- data/MIT-LICENSE +20 -0
- data/README.md +138 -0
- data/Rakefile +19 -0
- data/TODO +32 -0
- data/bin/mouth +77 -0
- data/bin/mouth-console +18 -0
- data/bin/mouth-endoscope +18 -0
- data/lib/mouth.rb +61 -0
- data/lib/mouth/dashboard.rb +25 -0
- data/lib/mouth/endoscope.rb +120 -0
- data/lib/mouth/endoscope/public/222222_256x240_icons_icons.png +0 -0
- data/lib/mouth/endoscope/public/application.css +464 -0
- data/lib/mouth/endoscope/public/application.js +938 -0
- data/lib/mouth/endoscope/public/backbone.js +1158 -0
- data/lib/mouth/endoscope/public/d3.js +4707 -0
- data/lib/mouth/endoscope/public/d3.time.js +687 -0
- data/lib/mouth/endoscope/public/jquery-ui-1.8.16.custom.min.js +177 -0
- data/lib/mouth/endoscope/public/jquery.js +4 -0
- data/lib/mouth/endoscope/public/json2.js +480 -0
- data/lib/mouth/endoscope/public/keymaster.js +163 -0
- data/lib/mouth/endoscope/public/linen.js +46 -0
- data/lib/mouth/endoscope/public/seven.css +68 -0
- data/lib/mouth/endoscope/public/seven.js +291 -0
- data/lib/mouth/endoscope/public/underscore.js +931 -0
- data/lib/mouth/endoscope/views/dashboard.erb +67 -0
- data/lib/mouth/graph.rb +58 -0
- data/lib/mouth/instrument.rb +56 -0
- data/lib/mouth/record.rb +72 -0
- data/lib/mouth/runner.rb +89 -0
- data/lib/mouth/sequence.rb +284 -0
- data/lib/mouth/source.rb +76 -0
- data/lib/mouth/sucker.rb +235 -0
- data/lib/mouth/version.rb +3 -0
- data/mouth.gemspec +28 -0
- data/test/sequence_test.rb +163 -0
- data/test/sucker_test.rb +55 -0
- data/test/test_helper.rb +5 -0
- metadata +167 -0
data/.gitignore
ADDED
data/Capfile
ADDED
@@ -0,0 +1,26 @@
|
|
1
|
+
load 'deploy'
|
2
|
+
|
3
|
+
# Simply way to deploy the app if you're developing it
|
4
|
+
# I will probably remove this file later.
|
5
|
+
set :application, "mouth"
|
6
|
+
default_run_options[:pty] = true
|
7
|
+
|
8
|
+
set :scm, :none
|
9
|
+
role :app, "sugar"
|
10
|
+
|
11
|
+
task :omg do
|
12
|
+
res = `gem build mouth.gemspec`
|
13
|
+
if res =~ /Success/
|
14
|
+
home = "#{capture('pwd').strip}/mouth"
|
15
|
+
file = res.match(/File: (.+)$/)[1]
|
16
|
+
run "mkdir -p ~/mouth"
|
17
|
+
loc = "#{home}/#{file}"
|
18
|
+
upload file, loc
|
19
|
+
sudo "gem install #{loc}"
|
20
|
+
run "mouth-endoscope -K"
|
21
|
+
run "mouth --pidfile #{home}/mouth.pid -K"
|
22
|
+
run "sleep 5"
|
23
|
+
run "mouth-endoscope --mongohost 10.36.103.70"
|
24
|
+
run "mouth --pidfile #{home}/mouth.pid --logfile #{home}/mouth.log -H 10.18.23.135 -P 8889 --mongohost 10.36.103.70"
|
25
|
+
end
|
26
|
+
end
|
data/Gemfile
ADDED
data/MIT-LICENSE
ADDED
@@ -0,0 +1,20 @@
|
|
1
|
+
Copyright (c) 2011 Jonathan Novak - http://github.com/cypriss
|
2
|
+
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
4
|
+
a copy of this software and associated documentation files (the
|
5
|
+
"Software"), to deal in the Software without restriction, including
|
6
|
+
without limitation the rights to use, copy, modify, merge, publish,
|
7
|
+
distribute, sublicense, and/or sell copies of the Software, and to
|
8
|
+
permit persons to whom the Software is furnished to do so, subject to
|
9
|
+
the following conditions:
|
10
|
+
|
11
|
+
The above copyright notice and this permission notice shall be
|
12
|
+
included in all copies or substantial portions of the Software.
|
13
|
+
|
14
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
15
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
16
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
17
|
+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
18
|
+
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
19
|
+
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
20
|
+
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/README.md
ADDED
@@ -0,0 +1,138 @@
|
|
1
|
+
# Mouth
|
2
|
+
|
3
|
+
Mouth is a Ruby daemon that collects stats via UDP and stores them in Mongo. It comes with a modern UI that allows you to view graphs and create dashboards of these statistics. Mouth is very similar to [StatsD](https://github.com/etsy/statsd) + [Graphite](http://graphite.wikidot.com/) + [Graphene](http://jondot.github.com/graphene/).
|
4
|
+
|
5
|
+
|
6
|
+
## Why
|
7
|
+
|
8
|
+
Why duplicate effort of the excellent StatsD / Graphite packages? I wanted a graphing and monitoring tool that offered:
|
9
|
+
|
10
|
+
|
11
|
+
* **Accessible data**. Because the metrics are stored in Mongo, they are very accessible to any scripts you want to write to read and act on your data.
|
12
|
+
* **Easy installation**. Mouth depends on just Ruby and Mongo, both incredibly easy to install.
|
13
|
+
* **Modern, friendly UI**. Mouth graphs are beautiful and easy to work with. Exploring data and building dashboards are a joy.
|
14
|
+
|
15
|
+
## Kinds of Metrics
|
16
|
+
|
17
|
+
There are two kinds of metrics currently: counters and timers. Both are stored in a time series with minute granularity.
|
18
|
+
|
19
|
+
* **Counters**:
|
20
|
+
* how many occurances of something happen per minute?
|
21
|
+
* Can be sampled
|
22
|
+
* Standard usage: Mouth.increment("myapp.happenings")
|
23
|
+
* Advanced usage: Mouth.increment("myapp.happenings", 1, 0.1) # 1/10 sample rate. UDP packets are sent 1 in 10 times, but count for 1 * 10 each.
|
24
|
+
* Gives you a time series of happenings by minute: {"myapp.happenings" => [1, 2, 5, 20, ...]}
|
25
|
+
* **Timers**:
|
26
|
+
* How long does something take?
|
27
|
+
* Standard usage: Mouth.measure("myapp.occurances", 3.3) # This occurance took 3.3 ms to occur
|
28
|
+
* Standard usage 2: Mouth.measure("myapp.occurances") { do_occurance() }
|
29
|
+
* Gives you a time series of occurance timings by minute: {"myapp.happenings" => [1, 2, 5, 20, ...]}
|
30
|
+
* **Gauges**: (Coming soon)
|
31
|
+
|
32
|
+
## Installation on OSX
|
33
|
+
|
34
|
+
Install mouth:
|
35
|
+
|
36
|
+
gem install mouth
|
37
|
+
|
38
|
+
Install MongoDB if you haven't:
|
39
|
+
|
40
|
+
brew install mongodb
|
41
|
+
|
42
|
+
Start collector:
|
43
|
+
|
44
|
+
mouth
|
45
|
+
|
46
|
+
Start web UI:
|
47
|
+
|
48
|
+
mouth-endoscope
|
49
|
+
|
50
|
+
Record a metric:
|
51
|
+
|
52
|
+
ruby -e 'require "mouth"; require "mouth/instrument"; Mouth.increment("gorets")'
|
53
|
+
|
54
|
+
To load the web UI, go to http://0.0.0.0:5678/ (or whatever port got chosen -- see the Terminal). Click 'Add Graph' in the lower right-hand corner.
|
55
|
+
|
56
|
+
## Installation in Production
|
57
|
+
|
58
|
+
You'll want to follow the general gist of what you did for OSX, but make sure to specify your hosts, ports, and log locations.
|
59
|
+
NOTE: there is no config file -- all options are via command-line.
|
60
|
+
|
61
|
+
sudo gem install mouth
|
62
|
+
mouth --pidfile /path/to/log/mouth.pid --logfile /path/to/log/mouth.log -H x.x.x.x -P 8889 --mongohost y.y.y.y --verbosity 1
|
63
|
+
mouth-endoscope --mongohost x.x.x.x
|
64
|
+
|
65
|
+
## Instrumenting Your Application to Record Metrics
|
66
|
+
|
67
|
+
There are many ways to instrument your application:
|
68
|
+
|
69
|
+
### Using the mouth gem
|
70
|
+
|
71
|
+
Mouth comes with a built-in facility to instrument your apps:
|
72
|
+
|
73
|
+
require 'mouth'
|
74
|
+
require 'mouth/instrument'
|
75
|
+
|
76
|
+
Mouth.server = "0.0.0.0:8889"
|
77
|
+
Mouth.increment('hello.world')
|
78
|
+
Mouth.measure('hello.happening', 42.9)
|
79
|
+
|
80
|
+
### Using mouth-instrument
|
81
|
+
|
82
|
+
mouth-instrument is a lightweight gem that doesn't have the baggage of the various gems that come with mouth. Its usage is nearly identical:
|
83
|
+
|
84
|
+
gem install mouth-instrument
|
85
|
+
|
86
|
+
require 'mouth-instrument'
|
87
|
+
|
88
|
+
Mouth.server = "0.0.0.0:8889"
|
89
|
+
Mouth.increment('hello.world')
|
90
|
+
Mouth.measure('hello.happening', 42.9)
|
91
|
+
|
92
|
+
### Using any StatsD instrumentation
|
93
|
+
|
94
|
+
Mouth is StatsD compatible -- if you've instrumented your application to record StatsD metrics, it should work on Mouth. Just replace your StatsD server with a mouth process.
|
95
|
+
|
96
|
+
## Accessing Your Data Via Scripts
|
97
|
+
|
98
|
+
You can access and act on your metrics quite easily.
|
99
|
+
|
100
|
+
require 'mouth'
|
101
|
+
require 'mouth/sequence'
|
102
|
+
|
103
|
+
Mouth.host = "0.0.0.0:8889"
|
104
|
+
Sequence.new("exceptions.app", :kind => :counter).sequence
|
105
|
+
# => [4, 9, 0, ...]
|
106
|
+
|
107
|
+
Sequence.new("app.requests", :kind => :timer, :granularity_in_minutes => 15, :start_time => Time.now - 86400, :end_time => Time.now).sequence
|
108
|
+
# => [{:count => 3, :min => 1, :max => 30, :mean => 17.0, :sum => 51.0, :median => 20, :stddev => 12.02}, ...]
|
109
|
+
|
110
|
+
## Tech
|
111
|
+
|
112
|
+
* **Ruby** - 1.9.2+ is required. Ruby was chosen because many Ruby shops already have it deployed as part of their infrastucture. By putting everything in Ruby, node + python aren't needed.
|
113
|
+
* **MongoDB** - Mouth stores metrics in Mongo. Mongo was chosen for 3 reasons:
|
114
|
+
* It's very easy to install and get going
|
115
|
+
* It has drivers for everything, so getting at your data is super easy
|
116
|
+
* It's schemaless design is fairly good for storing time series metrics.
|
117
|
+
* **EventMachine** - Mouth is powered by EM, the Ruby way of doing nonblocking IO.
|
118
|
+
* **Sinatra** - The web UI is served with a simple Sinatra app.
|
119
|
+
* **Backbone.js** - The web UI is powered by Backbone.js
|
120
|
+
* **D3.js** - The graphs are powered by D3.js
|
121
|
+
|
122
|
+
|
123
|
+
## Contributing
|
124
|
+
|
125
|
+
You're interested in contributing to Mouth? *AWESOME*. Here are the basic steps:
|
126
|
+
|
127
|
+
fork Mouth from here: http://github.com/cypriss/mouth
|
128
|
+
|
129
|
+
1. Clone your fork
|
130
|
+
2. Hack away
|
131
|
+
3. If you are adding new functionality, document it in the README
|
132
|
+
4. If necessary, rebase your commits into logical chunks, without errors
|
133
|
+
5. Push the branch up to GitHub
|
134
|
+
6. Send a pull request to the cypriss/mouth project.
|
135
|
+
|
136
|
+
## Thanks
|
137
|
+
|
138
|
+
Thanks to UserVoice.com for sponsoring this project. Thanks to the [StatsD](https://github.com/etsy/statsd) project for massive inspiration. Other contributors: https://github.com/cypriss/mouth/graphs/contributors
|
data/Rakefile
ADDED
@@ -0,0 +1,19 @@
|
|
1
|
+
require 'rubygems'
|
2
|
+
require 'bundler'
|
3
|
+
begin
|
4
|
+
Bundler.setup(:default, :development)
|
5
|
+
rescue Bundler::BundlerError => e
|
6
|
+
$stderr.puts e.message
|
7
|
+
$stderr.puts "Run `bundle install` to install missing gems"
|
8
|
+
exit e.status_code
|
9
|
+
end
|
10
|
+
require 'rake'
|
11
|
+
|
12
|
+
require 'rake/testtask'
|
13
|
+
Rake::TestTask.new(:test) do |test|
|
14
|
+
test.libs << 'lib' << 'test'
|
15
|
+
test.pattern = 'test/*_test.rb'
|
16
|
+
test.verbose = true
|
17
|
+
end
|
18
|
+
|
19
|
+
task :default => :test
|
data/TODO
ADDED
@@ -0,0 +1,32 @@
|
|
1
|
+
Next
|
2
|
+
- Cookie-ize the timespan settings
|
3
|
+
- Re implement graph autoplacement
|
4
|
+
- Better backend impl of source list
|
5
|
+
- Figure out how dashboards can scroll
|
6
|
+
- Show period averages / rates
|
7
|
+
- Full keyboard navigation
|
8
|
+
- Build in some mechanisms to diagnose mongo connection issues
|
9
|
+
- Auto refreshing source list (every minute?)
|
10
|
+
- More Tests
|
11
|
+
- Figure out time zones
|
12
|
+
|
13
|
+
Someday
|
14
|
+
- Picker:
|
15
|
+
- When you re-pick sources, it should scroll to the existing picked items
|
16
|
+
- Selected rows should also be highlighted in addition to checked
|
17
|
+
- should show a little sparkline or some sense of how old the data is
|
18
|
+
- Source picker textbox filter
|
19
|
+
- Manual refresh source list icon
|
20
|
+
- Graphing
|
21
|
+
- Restructure charting code to not remove everything when updating with new data
|
22
|
+
- Plugin system
|
23
|
+
- 'Dashboards' should be a module
|
24
|
+
- 'Explorer' module
|
25
|
+
- 'Monitor' module
|
26
|
+
|
27
|
+
Bugs:
|
28
|
+
- When picker is open, changing dashboards should hide picker
|
29
|
+
- tooltip spawns multiple copies on large datasets
|
30
|
+
|
31
|
+
Refactorings:
|
32
|
+
- reconcile naming of 'sources' sometimes being array of strings and sometimes array of objects
|
data/bin/mouth
ADDED
@@ -0,0 +1,77 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
|
3
|
+
$LOAD_PATH.unshift File.expand_path(File.dirname(__FILE__) + '/../lib')
|
4
|
+
|
5
|
+
require 'optparse'
|
6
|
+
require 'mouth'
|
7
|
+
require 'mouth/runner'
|
8
|
+
require 'mouth/sucker'
|
9
|
+
|
10
|
+
options = {
|
11
|
+
:verbosity => 0,
|
12
|
+
:host => "localhost",
|
13
|
+
:port => 8889
|
14
|
+
}
|
15
|
+
|
16
|
+
parser = OptionParser.new do |op|
|
17
|
+
op.banner = "Usage: mouth [options]"
|
18
|
+
|
19
|
+
op.separator ""
|
20
|
+
op.separator "Options:"
|
21
|
+
|
22
|
+
##
|
23
|
+
## Daemonization / Logging options
|
24
|
+
##
|
25
|
+
|
26
|
+
op.on("--pidfile PATH", "DO YOU WANT ME TO WRITE A PIDFILE SOMEWHERE FOR U?") do |pid_file|
|
27
|
+
options[:pid_file] = pid_file
|
28
|
+
end
|
29
|
+
|
30
|
+
op.on("--logfile PATH", "I'LL POOP OUT LOGS HERE FOR U") do |log_file|
|
31
|
+
options[:log_file] = log_file
|
32
|
+
end
|
33
|
+
|
34
|
+
op.on("-v", "--verbosity LEVEL", "HOW MUCH POOP DO U WANT IN UR LOGS? [LEVEL=0:errors,1:some,2:lots of poop]") do |verbosity|
|
35
|
+
options[:verbosity] = verbosity.to_i
|
36
|
+
end
|
37
|
+
|
38
|
+
op.on("-K", "--kill", "SHUT THE MOUTH") do
|
39
|
+
options[:kill] = true
|
40
|
+
end
|
41
|
+
|
42
|
+
##
|
43
|
+
## Socket to suck from
|
44
|
+
##
|
45
|
+
|
46
|
+
op.on("-H", "--host HOST", "I SUCK ON THIS NETWORK INTERFACE") do |host|
|
47
|
+
options[:host] = host
|
48
|
+
end
|
49
|
+
|
50
|
+
op.on("-P", "--port PORT", "I SUCK FROM THIS HOLE") do |port|
|
51
|
+
options[:port] = port.to_i
|
52
|
+
end
|
53
|
+
|
54
|
+
##
|
55
|
+
## Mongo
|
56
|
+
##
|
57
|
+
op.on("--mongodb DATABASE", "STORE SUCKINGS IN THIS DB") do |mongo_db|
|
58
|
+
options[:mongo_db] = mongo_db
|
59
|
+
end
|
60
|
+
|
61
|
+
# NOTE: this option can be given multiple times for a replica set
|
62
|
+
op.on("--mongohost HOSTPORT", "STORE SUCKINGS IN THIS MONGO") do |mongo_host|
|
63
|
+
options[:mongo_hosts] ||= []
|
64
|
+
options[:mongo_hosts] << mongo_host
|
65
|
+
end
|
66
|
+
|
67
|
+
op.on("-h", "--help", "I WANT MOAR HALP") do
|
68
|
+
puts op
|
69
|
+
exit
|
70
|
+
end
|
71
|
+
|
72
|
+
op.separator ""
|
73
|
+
end
|
74
|
+
|
75
|
+
parser.parse!
|
76
|
+
|
77
|
+
Mouth::Runner.new(options).run!
|
data/bin/mouth-console
ADDED
@@ -0,0 +1,18 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
|
3
|
+
# Running bin/mouth-console will put you in an IRB session with the mouth files loaded. Convenient for development and experimentation.
|
4
|
+
|
5
|
+
require 'irb'
|
6
|
+
require 'irb/completion'
|
7
|
+
|
8
|
+
$LOAD_PATH.unshift File.expand_path(File.dirname(__FILE__) + '/../lib')
|
9
|
+
|
10
|
+
require 'mouth'
|
11
|
+
require 'mouth/sequence'
|
12
|
+
require 'mouth/record'
|
13
|
+
require 'mouth/graph'
|
14
|
+
require 'mouth/dashboard'
|
15
|
+
require 'mouth/source'
|
16
|
+
require 'mouth/instrument'
|
17
|
+
|
18
|
+
IRB.start
|
data/bin/mouth-endoscope
ADDED
@@ -0,0 +1,18 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
|
3
|
+
$LOAD_PATH.unshift File.expand_path(File.dirname(__FILE__) + '/../lib')
|
4
|
+
|
5
|
+
require 'vegas'
|
6
|
+
require 'mouth'
|
7
|
+
require 'mouth/endoscope'
|
8
|
+
|
9
|
+
begin
|
10
|
+
require 'thin'
|
11
|
+
rescue LoadError
|
12
|
+
end
|
13
|
+
|
14
|
+
Vegas::Runner.new(Mouth::Endoscope, 'mouth-endoscope') do |runner, opts, app|
|
15
|
+
opts.on("--mongohost HOSTPORT", "STORE SUCKINGS IN THIS MONGO") do |mongo_host|
|
16
|
+
Mouth.mongo_host = mongo_host
|
17
|
+
end
|
18
|
+
end
|
data/lib/mouth.rb
ADDED
@@ -0,0 +1,61 @@
|
|
1
|
+
# Since the gem can be used in many ways (sucker daemon, sinatra app, instrument, ad-hoc, don't include anything)
|
2
|
+
require 'mouth/version'
|
3
|
+
|
4
|
+
module Mouth
|
5
|
+
class << self
|
6
|
+
attr_accessor :logger
|
7
|
+
attr_accessor :mongo_host
|
8
|
+
|
9
|
+
# Returns a mongo connection (NOT an em-mongo connection)
|
10
|
+
def mongo
|
11
|
+
@mongo ||= begin
|
12
|
+
require 'mongo' # require mongo here, as opposed to the top, because we don't want mongo included in the reactor (use em-mongo for that)
|
13
|
+
Mongo::Connection.new(self.mongo_host || "localhost").db("mouth")
|
14
|
+
end
|
15
|
+
end
|
16
|
+
|
17
|
+
def collection(collection_name)
|
18
|
+
@collections ||= {}
|
19
|
+
@collections[collection_name] ||= begin
|
20
|
+
c = mongo.collection(collection_name)
|
21
|
+
c.ensure_index([["t", 1]], {:background => true, :unique => true})
|
22
|
+
c
|
23
|
+
end
|
24
|
+
end
|
25
|
+
|
26
|
+
def mongo_collection_name(namespace)
|
27
|
+
"mouth_#{namespace}"
|
28
|
+
end
|
29
|
+
|
30
|
+
def sanitize_namespace(key)
|
31
|
+
key.gsub(/\s+/, '_').gsub(/\//, '-').gsub(/[^a-zA-Z_\-0-9]/, '')
|
32
|
+
end
|
33
|
+
|
34
|
+
def sanitize_metric(key)
|
35
|
+
key.gsub(/\s+/, '_').gsub(/[^a-zA-Z0-9\-_\/]/, '')
|
36
|
+
end
|
37
|
+
|
38
|
+
# Parses a key into two parts: namespace, and metric. Also sanitizes each field
|
39
|
+
# Returns an array of values: [namespace, metric]
|
40
|
+
# eg,
|
41
|
+
# parse_key("Ticket.process_new_ticket") # => ["Ticket", "process_new_ticket"]
|
42
|
+
# parse_key("Forum List.other! stuff.ok") # => ["Forum_List", "other_stuff-ok"]
|
43
|
+
# parse_key("no_namespace") # => ["default", "no_namespace"]
|
44
|
+
def parse_key(key)
|
45
|
+
parts = key.split(".")
|
46
|
+
namespace = nil
|
47
|
+
metric = nil
|
48
|
+
if parts.length > 1
|
49
|
+
namespace = parts.shift
|
50
|
+
metric = parts.join("-")
|
51
|
+
else
|
52
|
+
namespace = "default"
|
53
|
+
metric = parts.shift
|
54
|
+
end
|
55
|
+
|
56
|
+
[sanitize_namespace(namespace), sanitize_metric(metric)]
|
57
|
+
end
|
58
|
+
|
59
|
+
|
60
|
+
end
|
61
|
+
end
|
@@ -0,0 +1,25 @@
|
|
1
|
+
module Mouth
|
2
|
+
|
3
|
+
# A dashboard has these fields:
|
4
|
+
# id
|
5
|
+
# name
|
6
|
+
# todo: order
|
7
|
+
class Dashboard < Record
|
8
|
+
|
9
|
+
def all_attributes
|
10
|
+
self.attributes.tap {|attrs| attrs[:graphs] = graphs.collect(&:all_attributes) }
|
11
|
+
end
|
12
|
+
|
13
|
+
def destroy
|
14
|
+
self.graphs.each(&:destroy)
|
15
|
+
super
|
16
|
+
end
|
17
|
+
|
18
|
+
# An array of graphs
|
19
|
+
def graphs
|
20
|
+
@graphs ||= begin
|
21
|
+
Graph.for_dashboard(self.attributes[:id])
|
22
|
+
end
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|