funktor 0.4.6 → 0.6.1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (94) hide show
  1. checksums.yaml +4 -4
  2. data/.tool-versions +2 -0
  3. data/Gemfile.lock +24 -5
  4. data/funktor-testapp/.envrc +1 -0
  5. data/funktor-testapp/.gitignore +7 -0
  6. data/funktor-testapp/Gemfile +25 -0
  7. data/funktor-testapp/Gemfile.lock +51 -0
  8. data/funktor-testapp/app/services/job_flood.rb +38 -0
  9. data/funktor-testapp/app/workers/audit_worker.rb +49 -0
  10. data/funktor-testapp/app/workers/greetings_worker.rb +3 -0
  11. data/funktor-testapp/app/workers/hello_worker.rb +18 -0
  12. data/funktor-testapp/app/workers/single_thread_audit_worker.rb +3 -0
  13. data/funktor-testapp/deploy-dev.sh +5 -0
  14. data/funktor-testapp/funktor_config/boot.rb +17 -0
  15. data/funktor-testapp/funktor_config/environment.yml +15 -0
  16. data/funktor-testapp/funktor_config/function_definitions/default_queue_handler.yml +13 -0
  17. data/funktor-testapp/funktor_config/function_definitions/incoming_job_handler.yml +13 -0
  18. data/funktor-testapp/funktor_config/function_definitions/job_activator.yml +7 -0
  19. data/funktor-testapp/funktor_config/function_definitions/low_concurrency_queue_handler.yml +13 -0
  20. data/funktor-testapp/funktor_config/function_definitions/random_job_generator.yml +18 -0
  21. data/funktor-testapp/funktor_config/funktor.yml +114 -0
  22. data/funktor-testapp/funktor_config/iam_permissions/activity_table.yml +5 -0
  23. data/funktor-testapp/funktor_config/iam_permissions/default_queue.yml +8 -0
  24. data/funktor-testapp/funktor_config/iam_permissions/incoming_job_queue.yml +8 -0
  25. data/funktor-testapp/funktor_config/iam_permissions/jobs_table.yml +5 -0
  26. data/funktor-testapp/funktor_config/iam_permissions/jobs_table_secondary_index.yml +8 -0
  27. data/funktor-testapp/funktor_config/iam_permissions/low_concurrency_queue.yml +8 -0
  28. data/funktor-testapp/funktor_config/iam_permissions/ssm.yml +5 -0
  29. data/funktor-testapp/funktor_config/package.yml +11 -0
  30. data/funktor-testapp/funktor_config/resources/activity_table.yml +22 -0
  31. data/funktor-testapp/funktor_config/resources/cloudwatch_dashboard.yml +809 -0
  32. data/funktor-testapp/funktor_config/resources/default_queue.yml +22 -0
  33. data/funktor-testapp/funktor_config/resources/incoming_job_queue.yml +22 -0
  34. data/funktor-testapp/funktor_config/resources/incoming_job_queue_user.yml +26 -0
  35. data/funktor-testapp/funktor_config/resources/jobs_table.yml +56 -0
  36. data/funktor-testapp/funktor_config/resources/low_concurrency_queue.yml +22 -0
  37. data/funktor-testapp/funktor_config/ruby_layer.yml +11 -0
  38. data/funktor-testapp/funktor_init.yml +69 -0
  39. data/funktor-testapp/lambda_event_handlers/default_queue_handler.rb +8 -0
  40. data/funktor-testapp/lambda_event_handlers/incoming_job_handler.rb +8 -0
  41. data/funktor-testapp/lambda_event_handlers/job_activator.rb +8 -0
  42. data/funktor-testapp/lambda_event_handlers/low_concurrency_queue_handler.rb +8 -0
  43. data/funktor-testapp/lambda_event_handlers/random_job_generator.rb +35 -0
  44. data/funktor-testapp/package-lock.json +248 -0
  45. data/funktor-testapp/package.json +8 -0
  46. data/funktor-testapp/serverless.yml +66 -0
  47. data/funktor.gemspec +4 -1
  48. data/lib/active_job/queue_adapters/funktor_adapter.rb +3 -3
  49. data/lib/funktor/activity_tracker.rb +106 -0
  50. data/lib/funktor/cli/bootstrap.rb +0 -1
  51. data/lib/funktor/cli/init.rb +13 -0
  52. data/lib/funktor/cli/templates/app/workers/hello_worker.rb +1 -1
  53. data/lib/funktor/cli/templates/funktor_config/environment.yml +4 -0
  54. data/lib/funktor/cli/templates/funktor_config/function_definitions/incoming_job_handler.yml +3 -1
  55. data/lib/funktor/cli/templates/funktor_config/function_definitions/job_activator.yml +7 -0
  56. data/lib/funktor/cli/templates/funktor_config/function_definitions/work_queue_handler.yml +3 -1
  57. data/lib/funktor/cli/templates/funktor_config/funktor.yml +32 -6
  58. data/lib/funktor/cli/templates/funktor_config/iam_permissions/activity_table.yml +5 -0
  59. data/lib/funktor/cli/templates/funktor_config/iam_permissions/jobs_table.yml +5 -0
  60. data/lib/funktor/cli/templates/funktor_config/iam_permissions/jobs_table_secondary_index.yml +8 -0
  61. data/lib/funktor/cli/templates/funktor_config/resources/activity_table.yml +22 -0
  62. data/lib/funktor/cli/templates/funktor_config/resources/cloudwatch_dashboard.yml +13 -12
  63. data/lib/funktor/cli/templates/funktor_config/resources/incoming_job_queue.yml +2 -2
  64. data/lib/funktor/cli/templates/funktor_config/resources/jobs_table.yml +56 -0
  65. data/lib/funktor/cli/templates/funktor_config/resources/work_queue.yml +2 -2
  66. data/lib/funktor/cli/templates/funktor_init.yml.tt +16 -16
  67. data/lib/funktor/cli/templates/lambda_event_handlers/job_activator.rb +8 -0
  68. data/lib/funktor/cli/templates/lambda_event_handlers/work_queue_handler.rb +1 -1
  69. data/lib/funktor/cli/templates/serverless.yml +3 -2
  70. data/lib/funktor/counter.rb +4 -1
  71. data/lib/funktor/incoming_job_handler.rb +54 -18
  72. data/lib/funktor/job.rb +57 -7
  73. data/lib/funktor/job_activator.rb +124 -0
  74. data/lib/funktor/job_pusher.rb +0 -2
  75. data/lib/funktor/middleware/metrics.rb +8 -3
  76. data/lib/funktor/shard_utils.rb +6 -0
  77. data/lib/funktor/testing.rb +52 -29
  78. data/lib/funktor/version.rb +1 -1
  79. data/lib/funktor/web/application.rb +139 -0
  80. data/lib/funktor/web/views/index.erb +3 -0
  81. data/lib/funktor/web/views/layout.erb +58 -0
  82. data/lib/funktor/web/views/processing.erb +29 -0
  83. data/lib/funktor/web/views/queued.erb +29 -0
  84. data/lib/funktor/web/views/retries.erb +35 -0
  85. data/lib/funktor/web/views/scheduled.erb +26 -0
  86. data/lib/funktor/web/views/stats.erb +9 -0
  87. data/lib/funktor/web/views/table_stats_with_buttons.erb +11 -0
  88. data/lib/funktor/web.rb +1 -0
  89. data/lib/funktor/work_queue_handler.rb +101 -0
  90. data/lib/funktor/worker/funktor_options.rb +3 -1
  91. data/lib/funktor/worker.rb +8 -18
  92. data/lib/funktor.rb +52 -20
  93. metadata +109 -3
  94. data/lib/funktor/active_job_handler.rb +0 -58
@@ -0,0 +1,124 @@
1
+ require 'aws-sdk-dynamodb'
2
+ require 'aws-sdk-sqs'
3
+
4
+ module Funktor
5
+ class JobActivator
6
+
7
+ def initialize
8
+ @tracker = Funktor::ActivityTracker.new
9
+ end
10
+
11
+ def dynamodb_client
12
+ @dynamodb_client ||= ::Aws::DynamoDB::Client.new
13
+ end
14
+
15
+ def sqs_client
16
+ @sqs_client ||= ::Aws::SQS::Client.new
17
+ end
18
+
19
+ def delayed_job_table
20
+ ENV['FUNKTOR_JOBS_TABLE']
21
+ end
22
+
23
+ def jobs_to_activate
24
+ # TODO : The lookahead time here should be configurable
25
+ # If this doesn't match the setting in the IncomingJobHandler some jobs
26
+ # might be activated and then immediately re-scheduled instead of being
27
+ # queued, which leads to kind of confusing stats for the "incoming" stat.
28
+ # (Come to think of it, the incoming stat is kind of confusting anyway since
29
+ # it reflects retries and scheduled jobs activations...)
30
+ target_time = (Time.now + 60).utc
31
+ query_params = {
32
+ expression_attribute_values: {
33
+ ":queueable" => "true",
34
+ ":targetTime" => target_time.iso8601
35
+ },
36
+ key_condition_expression: "queueable = :queueable AND performAt < :targetTime",
37
+ projection_expression: "jobId, jobShard, category",
38
+ table_name: delayed_job_table,
39
+ index_name: "performAtIndex"
40
+ }
41
+ resp = dynamodb_client.query(query_params)
42
+ return resp.items
43
+ end
44
+
45
+ def queue_for_job(job)
46
+ queue_name = job.queue || 'default'
47
+ queue_constant = "FUNKTOR_#{queue_name.underscore.upcase}_QUEUE"
48
+ Funktor.logger.debug "queue_constant = #{queue_constant}"
49
+ Funktor.logger.debug "ENV value = #{ENV[queue_constant]}"
50
+ ENV[queue_constant] || ENV['FUNKTOR_DEFAULT_QUEUE']
51
+ end
52
+
53
+ def handle_item(item)
54
+ job_shard = item["jobShard"]
55
+ job_id = item["jobId"]
56
+ current_category = item["category"]
57
+ Funktor.logger.debug "jobShard = #{item['jobShard']}"
58
+ Funktor.logger.debug "jobId = #{item['jobId']}"
59
+ Funktor.logger.debug "current_category = #{current_category}"
60
+ activate_job(job_shard, job_id, current_category)
61
+ end
62
+
63
+ def activate_job(job_shard, job_id, current_category, queue_immediately = false)
64
+ # First we conditionally update the item in Dynamo to be sure that another scheduler hasn't gotten
65
+ # to it, and if that works then send to SQS. This is basically how Sidekiq scheduler works.
66
+ response = dynamodb_client.update_item({
67
+ key: {
68
+ "jobShard" => job_shard,
69
+ "jobId" => job_id
70
+ },
71
+ update_expression: "SET category = :category, queueable = :queueable",
72
+ condition_expression: "category = :current_category",
73
+ expression_attribute_values: {
74
+ ":current_category" => current_category,
75
+ ":queueable" => "false",
76
+ ":category" => "queued"
77
+ },
78
+ table_name: delayed_job_table,
79
+ return_values: "ALL_OLD"
80
+ })
81
+ if response.attributes # this means the record was still there in the state we expected
82
+ Funktor.logger.debug "response.attributes ====== "
83
+ Funktor.logger.debug response.attributes
84
+ job = Funktor::Job.new(response.attributes["payload"])
85
+ Funktor.logger.debug "we created a job from payload"
86
+ Funktor.logger.debug response.attributes["payload"]
87
+ Funktor.logger.debug "queueing to #{job.retry_queue_url}"
88
+ if queue_immediately
89
+ job.delay = 0
90
+ end
91
+ sqs_client.send_message({
92
+ queue_url: job.retry_queue_url,
93
+ message_body: job.to_json
94
+ #delay_seconds: job.delay
95
+ })
96
+ if job.is_retry?
97
+ # We don't track here because we send stuff back to the incoming job queue and we track the
98
+ # :retryActivated even there.
99
+ # TODO - Once we're sure this is all working right we can delete the commented out line.
100
+ #@tracker.track(:retryActivated, job)
101
+ else
102
+ @tracker.track(:scheduledJobActivated, job)
103
+ end
104
+ end
105
+ rescue ::Aws::DynamoDB::Errors::ConditionalCheckFailedException => e
106
+ # This means that a different instance of the JobActivator (or someone doing stuff in the web UI)
107
+ # got to the job first.
108
+ Funktor.logger.debug "#{e.to_s} : #{e.message}"
109
+ Funktor.logger.debug e.backtrace.join("\n")
110
+ end
111
+
112
+ def call(event:, context:)
113
+ handled_item_count = 0
114
+ jobs_to_activate.each do |item|
115
+ if context.get_remaining_time_in_millis < 5_000 # This lets us exit gracefully and resume on the next round instead of getting forcibly killed.
116
+ puts "Bailing out due to milliseconds remaining #{context.get_remaining_time_in_millis}"
117
+ break
118
+ end
119
+ handle_item(item)
120
+ handled_item_count += 1
121
+ end
122
+ end
123
+ end
124
+ end
@@ -4,8 +4,6 @@ module Funktor
4
4
  class JobPusher
5
5
 
6
6
  def push(payload)
7
- puts "payload ============"
8
- pp payload
9
7
  job_id = SecureRandom.uuid
10
8
  payload[:job_id] = job_id
11
9
 
@@ -10,7 +10,12 @@ module Funktor
10
10
  end
11
11
 
12
12
  def put_metric_to_stdout(time_diff, job)
13
- puts Funktor.dump_json(metric_hash(time_diff, job))
13
+ # NOTE : We use raw_logger here instead of Funktor.loggert o avoid getting extra
14
+ # timestamps or log level information in the log line. We need this specific format to
15
+ # be the only thing in the line so that CloudWatch can parse the logs and use the data.
16
+ # 'unknown' is a log level that will always be logged, no matter what is set in the
17
+ # runtime environment as far as log level.
18
+ Funktor.raw_logger.unknown Funktor.dump_json(metric_hash(time_diff, job))
14
19
  end
15
20
 
16
21
  def metric_hash(time_diff_in_seconds, job)
@@ -43,8 +48,8 @@ module Funktor
43
48
  end
44
49
  end
45
50
 
46
- Funktor.configure_active_job_handler do |config|
47
- config.active_job_handler_middleware do |chain|
51
+ Funktor.configure_work_queue_handler do |config|
52
+ config.work_queue_handler_middleware do |chain|
48
53
  chain.add Funktor::Middleware::Metrics
49
54
  end
50
55
  end
@@ -0,0 +1,6 @@
1
+ module ShardUtils
2
+ def calculate_shard(job_id)
3
+ # TODO - Should the number of shards be configurable?
4
+ job_id.sum % 64
5
+ end
6
+ end
@@ -1,7 +1,9 @@
1
1
  require 'funktor/worker'
2
+ require 'funktor/job_pusher'
2
3
  require 'funktor/fake_job_queue'
3
4
 
4
5
  module Funktor
6
+
5
7
  module Worker
6
8
  def self.clear_all
7
9
  Funktor::FakeJobQueue.clear_all
@@ -25,47 +27,68 @@ module Funktor
25
27
  end
26
28
  end
27
29
  end
30
+
28
31
  class Testing
32
+ class << self
33
+ attr_accessor :mode
29
34
 
30
- def self.inline!(&block)
31
- Funktor.configure_job_pusher do |config|
32
- config.job_pusher_middleware do |chain|
33
- chain.add Funktor::InlineJobPusherMiddleware
34
- end
35
+ def inline?
36
+ mode == :inline
35
37
  end
36
- yield
37
- Funktor.configure_job_pusher do |config|
38
- config.job_pusher_middleware do |chain|
39
- chain.remove Funktor::InlineJobPusherMiddleware
40
- end
38
+
39
+ def fake?
40
+ mode == :fake
41
41
  end
42
- end
43
- def self.fake!(&block)
44
- Funktor.configure_job_pusher do |config|
45
- config.job_pusher_middleware do |chain|
46
- chain.add Funktor::FakeJobPusherMiddleware
42
+
43
+ def inline!(&block)
44
+ unless block_given?
45
+ raise "Funktor inline testing mode can only be called in block form."
47
46
  end
47
+ set_mode(:inline, &block)
48
+ end
49
+
50
+ def fake!(&block)
51
+ set_mode(:fake, &block)
48
52
  end
49
- yield
50
- Funktor.configure_job_pusher do |config|
51
- config.job_pusher_middleware do |chain|
52
- chain.remove Funktor::FakeJobPusherMiddleware
53
+
54
+ def disable!
55
+ set_mode(:disabled)
56
+ end
57
+
58
+ def set_mode(new_mode, &block)
59
+ if block_given?
60
+ original_mode = mode
61
+ self.mode = new_mode
62
+ begin
63
+ yield
64
+ ensure
65
+ self.mode = original_mode
66
+ end
67
+ else
68
+ self.mode = new_mode
53
69
  end
54
70
  end
55
71
  end
56
72
  end
57
73
 
58
- class InlineJobPusherMiddleware
59
- def call(payload)
60
- payload = payload.with_indifferent_access
61
- worker = Object.const_get payload["worker"]
62
- worker.new.perform(*payload["worker_params"])
74
+ module TestingPusher
75
+ def push(payload)
76
+ if Funktor::Testing.inline?
77
+ Funktor.job_pusher_middleware.invoke(payload) do
78
+ payload = payload.with_indifferent_access
79
+ worker = Object.const_get payload["worker"]
80
+ worker.new.perform(*payload["worker_params"])
81
+ end
82
+ elsif Funktor::Testing.fake?
83
+ Funktor.job_pusher_middleware.invoke(payload) do
84
+ Funktor::FakeJobQueue.push(payload)
85
+ end
86
+ else
87
+ super
88
+ end
63
89
  end
64
90
  end
65
91
 
66
- class FakeJobPusherMiddleware
67
- def call(payload)
68
- Funktor::FakeJobQueue.push(payload)
69
- end
70
- end
92
+ Funktor::JobPusher.prepend TestingPusher
93
+
71
94
  end
@@ -1,3 +1,3 @@
1
1
  module Funktor
2
- VERSION = "0.4.6"
2
+ VERSION = "0.6.1"
3
3
  end
@@ -0,0 +1,139 @@
1
+ require 'sinatra/base'
2
+ require 'aws-sdk-dynamodb'
3
+ require_relative '../../funktor'
4
+ require_relative '../../funktor/shard_utils'
5
+ require_relative '../../funktor/activity_tracker'
6
+
7
+ module Funktor
8
+ module Web
9
+ class Application < Sinatra::Base
10
+ include ShardUtils
11
+
12
+ get '/' do
13
+ erb :index, layout: :layout, locals: {
14
+ activity_data: get_activity_data
15
+ }
16
+ end
17
+
18
+ get '/scheduled' do
19
+ erb :scheduled, layout: :layout, locals: {
20
+ activity_data: get_activity_data,
21
+ jobs: get_jobs('scheduled')
22
+ }
23
+ end
24
+
25
+ get '/retries' do
26
+ erb :retries, layout: :layout, locals: {
27
+ activity_data: get_activity_data,
28
+ jobs: get_jobs('retry')
29
+ }
30
+ end
31
+
32
+ get '/queued' do
33
+ erb :queued, layout: :layout, locals: {
34
+ activity_data: get_activity_data,
35
+ jobs: get_jobs('queued')
36
+ }
37
+ end
38
+
39
+ get '/processing' do
40
+ erb :processing, layout: :layout, locals: {
41
+ activity_data: get_activity_data,
42
+ jobs: get_jobs('processing')
43
+ }
44
+ end
45
+
46
+ post '/update_jobs' do
47
+ job_ids = params[:job_id]
48
+ if job_ids.is_a?(String)
49
+ job_ids = [job_ids]
50
+ end
51
+ job_ids ||= []
52
+ puts "params[:submit] = #{params[:submit]}"
53
+ puts "job_ids = #{job_ids}"
54
+ puts "params[:source] = #{params[:source]}"
55
+ if params[:submit] == "Delete Selected Jobs"
56
+ delete_jobs(job_ids, params[:source])
57
+ elsif params[:submit] == "Queue Selected Jobs"
58
+ queue_jobs(job_ids, params[:source])
59
+ end
60
+ redirect request.referrer
61
+ end
62
+
63
+ def get_jobs(category)
64
+ "Jobs of type #{category}"
65
+ query_params = {
66
+ expression_attribute_values: {
67
+ ":category" => category
68
+ },
69
+ key_condition_expression: "category = :category",
70
+ projection_expression: "payload, performAt, jobId, jobShard",
71
+ table_name: ENV['FUNKTOR_JOBS_TABLE'],
72
+ index_name: "categoryIndex"
73
+ }
74
+ resp = dynamodb_client.query(query_params)
75
+ @items = resp.items
76
+ @jobs = @items.map{ |item| Funktor::Job.new(item["payload"]) }
77
+ return @jobs
78
+ end
79
+
80
+ def get_activity_data
81
+ query_params = {
82
+ expression_attribute_values: {
83
+ ":category" => "stat"
84
+ },
85
+ key_condition_expression: "category = :category",
86
+ projection_expression: "statName, stat_value",
87
+ table_name: ENV['FUNKTOR_ACTIVITY_TABLE']
88
+ }
89
+ resp = dynamodb_client.query(query_params)
90
+ @activity_stats = {}
91
+ resp.items.each do |item|
92
+ @activity_stats[item["statName"]] = item["stat_value"].to_i
93
+ end
94
+ return @activity_stats
95
+ end
96
+
97
+ def queue_jobs(job_ids, source)
98
+ job_activator = Funktor::JobActivator.new
99
+ job_ids.each do |job_id|
100
+ job_shard = calculate_shard(job_id)
101
+ job_activator.activate_job(job_shard, job_id, source, true)
102
+ end
103
+ end
104
+
105
+ def delete_jobs(job_ids, source)
106
+ @tracker = Funktor::ActivityTracker.new
107
+ job_ids.each do |job_id|
108
+ delete_single_job(job_id, source)
109
+ end
110
+ end
111
+
112
+ def delete_single_job(job_id, source)
113
+ response = dynamodb_client.delete_item({
114
+ key: {
115
+ "jobShard" => calculate_shard(job_id),
116
+ "jobId" => job_id
117
+ },
118
+ table_name: ENV['FUNKTOR_JOBS_TABLE'],
119
+ return_values: "ALL_OLD"
120
+ })
121
+ if response.attributes # this means the record was still there
122
+ if source == "scheduled"
123
+ @tracker.track(:scheduledJobDeleted, nil)
124
+ elsif source == "retry"
125
+ @tracker.track(:retryDeleted, nil)
126
+ end
127
+ end
128
+ end
129
+
130
+ def dynamodb_client
131
+ @dynamodb_client ||= ::Aws::DynamoDB::Client.new
132
+ end
133
+
134
+ # start the server if ruby file executed directly
135
+ run! if app_file == $0
136
+ end
137
+ end
138
+ end
139
+
@@ -0,0 +1,3 @@
1
+ <p>Hellerrrrrrrrr!</p>
2
+ <p>Activity data = <%= activity_data %></p>
3
+
@@ -0,0 +1,58 @@
1
+ <html>
2
+ <head>
3
+ <link rel="stylesheet" href="https://unpkg.com/@picocss/pico@latest/css/pico.min.css">
4
+ <style type="text/css" media="screen">
5
+ /* Green Light scheme (Default) */
6
+ /* Can be forced with data-theme="light" */
7
+ [data-theme="light"],
8
+ :root:not([data-theme="dark"]) {
9
+ --primary: #43a047;
10
+ --primary-hover: #388e3c;
11
+ --primary-focus: rgba(67, 160, 71, 0.125);
12
+ --primary-inverse: #FFF;
13
+ }
14
+
15
+ /* Green Dark scheme (Auto) */
16
+ /* Automatically enabled if user has Dark mode enabled */
17
+ @media only screen and (prefers-color-scheme: dark) {
18
+ :root:not([data-theme="light"]) {
19
+ --primary: #43a047;
20
+ --primary-hover: #4caf50;
21
+ --primary-focus: rgba(67, 160, 71, 0.25);
22
+ --primary-inverse: #FFF;
23
+ }
24
+ }
25
+
26
+ /* Green Dark scheme (Forced) */
27
+ /* Enabled if forced with data-theme="dark" */
28
+ [data-theme="dark"] {
29
+ --primary: #43a047;
30
+ --primary-hover: #4caf50;
31
+ --primary-focus: rgba(67, 160, 71, 0.25);
32
+ --primary-inverse: #FFF;
33
+ }
34
+
35
+ /* Green (Common styles) */
36
+ :root {
37
+ --form-element-active-border-color: var(--primary);
38
+ --form-element-focus-color: var(--primary-focus);
39
+ --switch-color: var(--primary-inverse);
40
+ --switch-checked-background-color: var(--primary);
41
+ }
42
+
43
+ /* custom stuff for the funktor dashboard */
44
+ table.header h5{
45
+ margin-bottom: 0;
46
+ }
47
+ body > main{
48
+ padding: 0;
49
+ }
50
+ </style>
51
+ </head>
52
+ <body>
53
+ <%= erb :stats %>
54
+ <main class="container-fluid">
55
+ <%= yield %>
56
+ </main>
57
+ </body>
58
+ </html>