cloud-crowd 0.1.0
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/EPIGRAPHS +17 -0
- data/LICENSE +22 -0
- data/README +93 -0
- data/actions/graphics_magick.rb +43 -0
- data/actions/process_pdfs.rb +92 -0
- data/actions/word_count.rb +14 -0
- data/bin/crowd +5 -0
- data/cloud-crowd.gemspec +111 -0
- data/config/config.example.ru +17 -0
- data/config/config.example.yml +48 -0
- data/config/database.example.yml +9 -0
- data/examples/graphics_magick_example.rb +44 -0
- data/examples/process_pdfs_example.rb +40 -0
- data/examples/word_count_example.rb +41 -0
- data/lib/cloud-crowd.rb +130 -0
- data/lib/cloud_crowd/action.rb +101 -0
- data/lib/cloud_crowd/app.rb +117 -0
- data/lib/cloud_crowd/asset_store.rb +41 -0
- data/lib/cloud_crowd/asset_store/filesystem_store.rb +28 -0
- data/lib/cloud_crowd/asset_store/s3_store.rb +40 -0
- data/lib/cloud_crowd/command_line.rb +209 -0
- data/lib/cloud_crowd/daemon.rb +95 -0
- data/lib/cloud_crowd/exceptions.rb +28 -0
- data/lib/cloud_crowd/helpers.rb +8 -0
- data/lib/cloud_crowd/helpers/authorization.rb +50 -0
- data/lib/cloud_crowd/helpers/resources.rb +45 -0
- data/lib/cloud_crowd/inflector.rb +19 -0
- data/lib/cloud_crowd/models.rb +40 -0
- data/lib/cloud_crowd/models/job.rb +176 -0
- data/lib/cloud_crowd/models/work_unit.rb +89 -0
- data/lib/cloud_crowd/models/worker_record.rb +61 -0
- data/lib/cloud_crowd/runner.rb +15 -0
- data/lib/cloud_crowd/schema.rb +45 -0
- data/lib/cloud_crowd/worker.rb +186 -0
- data/public/css/admin_console.css +221 -0
- data/public/css/reset.css +42 -0
- data/public/images/bullet_green.png +0 -0
- data/public/images/bullet_white.png +0 -0
- data/public/images/cloud_hand.png +0 -0
- data/public/images/header_back.png +0 -0
- data/public/images/logo.png +0 -0
- data/public/images/queue_fill.png +0 -0
- data/public/images/server_error.png +0 -0
- data/public/images/sidebar_bottom.png +0 -0
- data/public/images/sidebar_top.png +0 -0
- data/public/images/worker_info.png +0 -0
- data/public/images/worker_info_loading.gif +0 -0
- data/public/js/admin_console.js +168 -0
- data/public/js/excanvas.js +1 -0
- data/public/js/flot.js +1 -0
- data/public/js/jquery.js +19 -0
- data/test/acceptance/test_app.rb +72 -0
- data/test/acceptance/test_failing_work_units.rb +32 -0
- data/test/acceptance/test_word_count.rb +49 -0
- data/test/blueprints.rb +17 -0
- data/test/config/actions/failure_testing.rb +13 -0
- data/test/config/config.ru +17 -0
- data/test/config/config.yml +7 -0
- data/test/config/database.yml +6 -0
- data/test/test_helper.rb +19 -0
- data/test/unit/test_action.rb +49 -0
- data/test/unit/test_configuration.rb +28 -0
- data/test/unit/test_job.rb +78 -0
- data/test/unit/test_work_unit.rb +55 -0
- data/views/index.erb +77 -0
- metadata +233 -0
@@ -0,0 +1,15 @@
|
|
1
|
+
# This is the script that kicks off a single CloudCrowd::Daemon. Rely on
|
2
|
+
# cloud-crowd.rb for autoloading of all the code we need.
|
3
|
+
|
4
|
+
require "#{File.dirname(__FILE__)}/../cloud-crowd"
|
5
|
+
|
6
|
+
FileUtils.mkdir('log') unless File.exists?('log')
|
7
|
+
|
8
|
+
Daemons.run("#{CloudCrowd::ROOT}/lib/cloud_crowd/daemon.rb", {
|
9
|
+
:app_name => "cloud_crowd_worker",
|
10
|
+
:dir_mode => :normal,
|
11
|
+
:dir => 'log',
|
12
|
+
:multiple => true,
|
13
|
+
:backtrace => true,
|
14
|
+
:log_output => true
|
15
|
+
})
|
@@ -0,0 +1,45 @@
|
|
1
|
+
# Complete schema for CloudCrowd.
|
2
|
+
ActiveRecord::Schema.define(:version => 1) do
|
3
|
+
|
4
|
+
create_table "jobs", :force => true do |t|
|
5
|
+
t.integer "status", :null => false
|
6
|
+
t.text "inputs", :null => false
|
7
|
+
t.string "action", :null => false
|
8
|
+
t.text "options", :null => false
|
9
|
+
t.text "outputs"
|
10
|
+
t.float "time"
|
11
|
+
t.string "callback_url"
|
12
|
+
t.string "email"
|
13
|
+
t.integer "lock_version", :default => 0, :null => false
|
14
|
+
t.datetime "created_at"
|
15
|
+
t.datetime "updated_at"
|
16
|
+
end
|
17
|
+
|
18
|
+
create_table "work_units", :force => true do |t|
|
19
|
+
t.integer "status", :null => false
|
20
|
+
t.integer "job_id", :null => false
|
21
|
+
t.text "input", :null => false
|
22
|
+
t.string "action", :null => false
|
23
|
+
t.integer "attempts", :default => 0, :null => false
|
24
|
+
t.integer "lock_version", :default => 0, :null => false
|
25
|
+
t.integer "worker_record_id"
|
26
|
+
t.float "time"
|
27
|
+
t.text "output"
|
28
|
+
t.datetime "created_at"
|
29
|
+
t.datetime "updated_at"
|
30
|
+
end
|
31
|
+
|
32
|
+
create_table "worker_records", :force => true do |t|
|
33
|
+
t.string "name", :null => false
|
34
|
+
t.string "thread_status", :null => false
|
35
|
+
t.datetime "created_at"
|
36
|
+
t.datetime "updated_at"
|
37
|
+
end
|
38
|
+
|
39
|
+
add_index "jobs", ["status"], :name => "index_jobs_on_status"
|
40
|
+
add_index "work_units", ["job_id"], :name => "index_work_units_on_job_id"
|
41
|
+
add_index "work_units", ["status", "worker_record_id", "action"], :name => "index_work_units_on_status_and_worker_record_id_and_action"
|
42
|
+
add_index "worker_records", ["name"], :name => "index_worker_records_on_name"
|
43
|
+
add_index "worker_records", ["updated_at"], :name => "index_worker_records_on_updated_at"
|
44
|
+
|
45
|
+
end
|
@@ -0,0 +1,186 @@
|
|
1
|
+
module CloudCrowd
|
2
|
+
|
3
|
+
# The Worker, run at intervals by the Daemon, fetches WorkUnits from the
|
4
|
+
# central server and dispatches Actions to process them. Workers only fetch
|
5
|
+
# units that they are able to handle (for which they have an action in their
|
6
|
+
# actions directory). If communication with the central server is interrupted,
|
7
|
+
# the WorkUnit will repeatedly attempt to complete its unit -- every
|
8
|
+
# Worker::RETRY_WAIT seconds. Any exceptions that take place during
|
9
|
+
# the course of the Action will cause the Worker to mark the WorkUnit as
|
10
|
+
# having failed.
|
11
|
+
class Worker
|
12
|
+
|
13
|
+
# The time between worker check-ins with the central server, informing
|
14
|
+
# it of the current status, and simply that it's still alive.
|
15
|
+
CHECK_IN_INTERVAL = 60
|
16
|
+
|
17
|
+
# Wait five seconds to retry, after internal communcication errors.
|
18
|
+
RETRY_WAIT = 5
|
19
|
+
|
20
|
+
attr_reader :action
|
21
|
+
|
22
|
+
# Spinning up a worker will create a new AssetStore with a persistent
|
23
|
+
# connection to S3. This AssetStore gets passed into each action, for use
|
24
|
+
# as it is run.
|
25
|
+
def initialize
|
26
|
+
@id = $$
|
27
|
+
@hostname = Socket.gethostname
|
28
|
+
@name = "#{@id}@#{@hostname}"
|
29
|
+
@store = AssetStore.new
|
30
|
+
@server = CloudCrowd.central_server
|
31
|
+
@enabled_actions = CloudCrowd.actions.keys
|
32
|
+
log 'started'
|
33
|
+
end
|
34
|
+
|
35
|
+
# Ask the central server for the first WorkUnit in line.
|
36
|
+
def fetch_work_unit
|
37
|
+
keep_trying_to "fetch a new work unit" do
|
38
|
+
unit_json = @server['/work'].post(base_params)
|
39
|
+
setup_work_unit(unit_json)
|
40
|
+
end
|
41
|
+
end
|
42
|
+
|
43
|
+
# Return output to the central server, marking the current work unit as done.
|
44
|
+
def complete_work_unit(result)
|
45
|
+
keep_trying_to "complete work unit" do
|
46
|
+
data = completion_params.merge({:status => 'succeeded', :output => result})
|
47
|
+
unit_json = @server["/work/#{data[:id]}"].put(data)
|
48
|
+
log "finished #{display_work_unit} in #{data[:time]} seconds"
|
49
|
+
clear_work_unit
|
50
|
+
setup_work_unit(unit_json)
|
51
|
+
end
|
52
|
+
end
|
53
|
+
|
54
|
+
# Mark the current work unit as failed, returning the exception to central.
|
55
|
+
def fail_work_unit(exception)
|
56
|
+
keep_trying_to "mark work unit as failed" do
|
57
|
+
data = completion_params.merge({:status => 'failed', :output => {'output' => exception.message}.to_json})
|
58
|
+
unit_json = @server["/work/#{data[:id]}"].put(data)
|
59
|
+
log "failed #{display_work_unit} in #{data[:time]} seconds\n#{exception.message}\n#{exception.backtrace}"
|
60
|
+
clear_work_unit
|
61
|
+
setup_work_unit(unit_json)
|
62
|
+
end
|
63
|
+
end
|
64
|
+
|
65
|
+
# Check in with the central server. Let it know the condition of the work
|
66
|
+
# thread, the action and status we're processing, and our hostname and PID.
|
67
|
+
def check_in(thread_status)
|
68
|
+
keep_trying_to "check in with central" do
|
69
|
+
@server["/worker"].put({
|
70
|
+
:name => @name,
|
71
|
+
:thread_status => thread_status
|
72
|
+
})
|
73
|
+
end
|
74
|
+
end
|
75
|
+
|
76
|
+
# Inform the central server that this worker is finished. This is the only
|
77
|
+
# remote method that doesn't retry on connection errors -- if the worker
|
78
|
+
# can't connect to the central server while it's trying to shutdown, it
|
79
|
+
# should close, regardless.
|
80
|
+
def check_out
|
81
|
+
@server["/worker"].put({
|
82
|
+
:name => @name,
|
83
|
+
:terminated => true
|
84
|
+
})
|
85
|
+
log 'exiting'
|
86
|
+
end
|
87
|
+
|
88
|
+
# We expect and require internal communication between the central server
|
89
|
+
# and the workers to succeed. If it fails for any reason, log it, and then
|
90
|
+
# keep trying the same request.
|
91
|
+
def keep_trying_to(title)
|
92
|
+
begin
|
93
|
+
yield
|
94
|
+
rescue Exception => e
|
95
|
+
log "failed to #{title} -- retry in #{RETRY_WAIT} seconds"
|
96
|
+
log e.message
|
97
|
+
log e.backtrace
|
98
|
+
sleep RETRY_WAIT
|
99
|
+
retry
|
100
|
+
end
|
101
|
+
end
|
102
|
+
|
103
|
+
# Does this Worker have a job to do?
|
104
|
+
def has_work?
|
105
|
+
@action_name && @input && @options
|
106
|
+
end
|
107
|
+
|
108
|
+
# Loggable string of the current work unit.
|
109
|
+
def display_work_unit
|
110
|
+
"unit ##{@options['work_unit_id']} (#{@action_name})"
|
111
|
+
end
|
112
|
+
|
113
|
+
# Executes the current work unit, catching all exceptions as failures.
|
114
|
+
def run_work_unit
|
115
|
+
begin
|
116
|
+
result = nil
|
117
|
+
@action = CloudCrowd.actions[@action_name].new(@status, @input, @options, @store)
|
118
|
+
Dir.chdir(@action.work_directory) do
|
119
|
+
result = case @status
|
120
|
+
when PROCESSING then @action.process
|
121
|
+
when SPLITTING then @action.split
|
122
|
+
when MERGING then @action.merge
|
123
|
+
else raise Error::StatusUnspecified, "work units must specify their status"
|
124
|
+
end
|
125
|
+
end
|
126
|
+
complete_work_unit({'output' => result}.to_json)
|
127
|
+
rescue Exception => e
|
128
|
+
fail_work_unit(e)
|
129
|
+
end
|
130
|
+
end
|
131
|
+
|
132
|
+
# Wraps <tt>run_work_unit</tt> to benchmark the execution time, if requested.
|
133
|
+
def run
|
134
|
+
return run_work_unit unless @options['benchmark']
|
135
|
+
status = CloudCrowd.display_status(@status)
|
136
|
+
log("ran #{@action_name}/#{status} in " + Benchmark.measure { run_work_unit }.to_s)
|
137
|
+
end
|
138
|
+
|
139
|
+
|
140
|
+
private
|
141
|
+
|
142
|
+
# Common parameters to send back to central.
|
143
|
+
def base_params
|
144
|
+
@base_params ||= {
|
145
|
+
:worker_name => @name,
|
146
|
+
:worker_actions => @enabled_actions.join(',')
|
147
|
+
}
|
148
|
+
end
|
149
|
+
|
150
|
+
# Common parameters to send back to central upon unit completion,
|
151
|
+
# regardless of success or failure.
|
152
|
+
def completion_params
|
153
|
+
base_params.merge({
|
154
|
+
:id => @options['work_unit_id'],
|
155
|
+
:time => Time.now - @start_time
|
156
|
+
})
|
157
|
+
end
|
158
|
+
|
159
|
+
# Extract our instance variables from a WorkUnit's JSON.
|
160
|
+
def setup_work_unit(unit_json)
|
161
|
+
return false unless unit_json
|
162
|
+
unit = JSON.parse(unit_json)
|
163
|
+
@start_time = Time.now
|
164
|
+
@action_name, @input, @options, @status = unit['action'], unit['input'], unit['options'], unit['status']
|
165
|
+
@options['job_id'] = unit['job_id']
|
166
|
+
@options['work_unit_id'] = unit['id']
|
167
|
+
@options['attempts'] ||= unit['attempts']
|
168
|
+
log "fetched #{display_work_unit}"
|
169
|
+
return true
|
170
|
+
end
|
171
|
+
|
172
|
+
# Log a message to the daemon log. Includes PID for identification.
|
173
|
+
def log(message)
|
174
|
+
puts "Worker ##{@id}: #{message}" unless ENV['RACK_ENV'] == 'test'
|
175
|
+
end
|
176
|
+
|
177
|
+
# When we're done with a unit, clear out our instance variables to make way
|
178
|
+
# for the next one. Also, remove all of the unit's temporary storage.
|
179
|
+
def clear_work_unit
|
180
|
+
@action.cleanup_work_directory
|
181
|
+
@action, @action_name, @input, @options, @start_time = nil, nil, nil, nil, nil
|
182
|
+
end
|
183
|
+
|
184
|
+
end
|
185
|
+
|
186
|
+
end
|
@@ -0,0 +1,221 @@
|
|
1
|
+
body {
|
2
|
+
background: #979797;
|
3
|
+
font-family: Arial;
|
4
|
+
font-size: 12px;
|
5
|
+
color: #252525;
|
6
|
+
}
|
7
|
+
|
8
|
+
.small_caps {
|
9
|
+
font: 11px Arial, sans-serif;
|
10
|
+
text-transform: uppercase;
|
11
|
+
}
|
12
|
+
|
13
|
+
#header {
|
14
|
+
height: 110px;
|
15
|
+
position: absolute;
|
16
|
+
top: 0; left: 0; right: 0;
|
17
|
+
background: url(/images/header_back.png);
|
18
|
+
}
|
19
|
+
#logo {
|
20
|
+
position: absolute;
|
21
|
+
left: 37px; top: 9px;
|
22
|
+
width: 236px; height: 91px;
|
23
|
+
background: url(/images/logo.png);
|
24
|
+
}
|
25
|
+
|
26
|
+
#disconnected {
|
27
|
+
position: absolute;
|
28
|
+
top: 122px; right: 15px;
|
29
|
+
background: #7f7f7f;
|
30
|
+
color: #333;
|
31
|
+
border: 1px solid #555;
|
32
|
+
font-size: 10px;
|
33
|
+
line-height: 18px;
|
34
|
+
padding: 3px 4px 1px 4px;
|
35
|
+
-moz-border-radius: 5px; -webkit-border-radius: 5px; border-radius: 5px;
|
36
|
+
}
|
37
|
+
#disconnected .server_error {
|
38
|
+
float: left;
|
39
|
+
width: 16px; height: 16px;
|
40
|
+
background: url(/images/server_error.png);
|
41
|
+
opacity: 0.7;
|
42
|
+
margin-right: 3px;
|
43
|
+
}
|
44
|
+
|
45
|
+
#queue {
|
46
|
+
position: absolute;
|
47
|
+
top: 16px; left: 327px; right: 15px;
|
48
|
+
height: 77px;
|
49
|
+
overflow: hidden;
|
50
|
+
}
|
51
|
+
#no_jobs {
|
52
|
+
text-align: left;
|
53
|
+
position: absolute;
|
54
|
+
bottom: 8px; right: 8px;
|
55
|
+
color: #999;
|
56
|
+
display: none;
|
57
|
+
}
|
58
|
+
#queue.no_jobs #no_jobs {
|
59
|
+
display: block;
|
60
|
+
}
|
61
|
+
#queue_fill {
|
62
|
+
position: absolute;
|
63
|
+
left: 0; right: 0; top: 0;
|
64
|
+
height: 75px;
|
65
|
+
border: 1px solid #5c5c5c;
|
66
|
+
-moz-border-radius: 10px; -webkit-border-radius: 10px; border-radius: 10px;
|
67
|
+
background: transparent url(/images/queue_fill.png) repeat-x 0px -1px;
|
68
|
+
}
|
69
|
+
#queue.no_jobs #queue_fill {
|
70
|
+
opacity: 0.3;
|
71
|
+
}
|
72
|
+
#queue .job {
|
73
|
+
position: relative;
|
74
|
+
margin-top: 1px;
|
75
|
+
height: 75px;
|
76
|
+
background: blue;
|
77
|
+
float: left;
|
78
|
+
overflow: hidden;
|
79
|
+
-moz-border-radius: 10px;
|
80
|
+
-webkit-border-radius: 10px;
|
81
|
+
}
|
82
|
+
#queue .completion {
|
83
|
+
position: absolute;
|
84
|
+
bottom: -1px;
|
85
|
+
height: 30px;
|
86
|
+
background: black;
|
87
|
+
border: 1px solid white;
|
88
|
+
-moz-border-radius: 10px; -webkit-border-radius: 10px;
|
89
|
+
opacity: 0.5;
|
90
|
+
overflow: hidden;
|
91
|
+
}
|
92
|
+
#queue .completion.zero {
|
93
|
+
border: 0;
|
94
|
+
}
|
95
|
+
#queue .percent_complete {
|
96
|
+
position: absolute;
|
97
|
+
bottom: 8px; left: 8px;
|
98
|
+
color: #c7c7c7;
|
99
|
+
z-index: 10;
|
100
|
+
}
|
101
|
+
#queue .job_id {
|
102
|
+
color: #333;
|
103
|
+
font-size: 14px;
|
104
|
+
position: absolute;
|
105
|
+
top: 8px; left: 8px;
|
106
|
+
z-index: 10;
|
107
|
+
}
|
108
|
+
|
109
|
+
#sidebar {
|
110
|
+
position: absolute;
|
111
|
+
top: 120px; left: 10px; bottom: 10px;
|
112
|
+
width: 300px;
|
113
|
+
overflow: hidden;
|
114
|
+
}
|
115
|
+
#sidebar_background {
|
116
|
+
position: absolute;
|
117
|
+
top: 21px; bottom: 21px;
|
118
|
+
width: 298px;
|
119
|
+
background: #e0e0e0;
|
120
|
+
border: 1px solid #8b8b8b;
|
121
|
+
border-top: 0; border-bottom: 0;
|
122
|
+
}
|
123
|
+
.sidebar_back {
|
124
|
+
position: absolute;
|
125
|
+
height: 21px; width: 300px;
|
126
|
+
}
|
127
|
+
#sidebar_top {
|
128
|
+
top: 0px;
|
129
|
+
background: url(/images/sidebar_top.png);
|
130
|
+
}
|
131
|
+
#sidebar_bottom {
|
132
|
+
bottom: 0px;
|
133
|
+
background: url(/images/sidebar_bottom.png);
|
134
|
+
}
|
135
|
+
#sidebar_header {
|
136
|
+
position: absolute;
|
137
|
+
top: 5px; left: 8px;
|
138
|
+
color: #404040;
|
139
|
+
text-shadow: 0px 1px 1px #eee;
|
140
|
+
}
|
141
|
+
#sidebar_header.no_workers .no_workers,
|
142
|
+
#sidebar_header .has_workers {
|
143
|
+
display: block;
|
144
|
+
}
|
145
|
+
#sidebar_header .no_workers,
|
146
|
+
#sidebar_header.no_workers .has_workers {
|
147
|
+
display: none;
|
148
|
+
}
|
149
|
+
#workers {
|
150
|
+
position: absolute;
|
151
|
+
padding: 2px 0;
|
152
|
+
top: 21px; left: 0; bottom: 21px;
|
153
|
+
width: 298px;
|
154
|
+
overflow-y: auto; overflow-x: hidden;
|
155
|
+
}
|
156
|
+
#workers .worker {
|
157
|
+
border: 1px solid transparent;
|
158
|
+
margin: 1px 7px;
|
159
|
+
padding-left: 18px;
|
160
|
+
font-size: 11px;
|
161
|
+
line-height: 22px;
|
162
|
+
background: url(/images/bullet_white.png) no-repeat left center;
|
163
|
+
cursor: pointer;
|
164
|
+
}
|
165
|
+
#workers .worker.processing,
|
166
|
+
#workers .worker.splitting,
|
167
|
+
#workers .worker.merging {
|
168
|
+
background: url(/images/bullet_green.png) no-repeat left center;
|
169
|
+
}
|
170
|
+
#workers .worker:hover {
|
171
|
+
border: 1px solid #aaa;
|
172
|
+
border-radius: 4px; -moz-border-radius: 4px; -webkit-border-radius: 4px;
|
173
|
+
background-color: #ccc;
|
174
|
+
}
|
175
|
+
|
176
|
+
#worker_info {
|
177
|
+
position: absolute;
|
178
|
+
width: 231px; height: 79px;
|
179
|
+
margin: -9px 0 0 -20px;
|
180
|
+
background: url(/images/worker_info.png);
|
181
|
+
overflow: hidden;
|
182
|
+
cursor: pointer;
|
183
|
+
}
|
184
|
+
#worker_info_inner {
|
185
|
+
margin: 15px 15px 15px 32px;
|
186
|
+
line-height: 15px;
|
187
|
+
color: #333;
|
188
|
+
text-shadow: 0px 1px 1px #eee;
|
189
|
+
}
|
190
|
+
#worker_info.loading #worker_info_inner {
|
191
|
+
background: url(/images/worker_info_loading.gif) no-repeat right bottom;
|
192
|
+
width: 45px; height: 9px;
|
193
|
+
}
|
194
|
+
#worker_info.awake #worker_details,
|
195
|
+
#worker_sleeping {
|
196
|
+
display: block;
|
197
|
+
}
|
198
|
+
#worker_details, #worker_info.loading #worker_details,
|
199
|
+
#worker_info.loading #worker_sleeping, #worker_info.awake #worker_sleeping {
|
200
|
+
display: none;
|
201
|
+
}
|
202
|
+
|
203
|
+
#graphs {
|
204
|
+
position: absolute;
|
205
|
+
padding: 17px 15px 15px 17px;
|
206
|
+
top: 110px; left: 310px; right: 0px; bottom: 0;
|
207
|
+
overflow: hidden;
|
208
|
+
overflow-y: auto;
|
209
|
+
}
|
210
|
+
.graph_container {
|
211
|
+
margin-bottom: 25px;
|
212
|
+
}
|
213
|
+
.graph_title {
|
214
|
+
color: #333;
|
215
|
+
font-size: 16px;
|
216
|
+
text-shadow: 0px 1px 1px #eee;
|
217
|
+
margin-bottom: 10px;
|
218
|
+
}
|
219
|
+
.graph {
|
220
|
+
height: 150px;
|
221
|
+
}
|