solid_queue_heroku_autoscaler 0.1.0 → 0.2.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d232e8e59b0f059186709890d2b293b9498cbee779620582e58540908af2ce49
4
- data.tar.gz: 9fad9708b81ca955208c77095a1f395c513e89d3524cfdb7d30d8357fb4fe346
3
+ metadata.gz: b7b31454d3f2b02f066a5ebf62b70e758a572f5a0ab406b6d090ffdb12e819c8
4
+ data.tar.gz: 90567a30026883b659cfac5b4fe3b1204e330781da31b1dfe4db0960f2336531
5
5
  SHA512:
6
- metadata.gz: a61ccb5462fe1dfa0941c857aa1c60cf61a62e50338d13b122904b2d0208c3a9dd615a72dd320ceb124d6540b36453e070ca76657e66160b83a8aaeb04be622b
7
- data.tar.gz: aec6a58d0243d07ef9ecc289b7dff1bbfa87dc8e031d2d92d44a497ef48efcf7d914399611b938be372ad5fca578a58cb5cc34a50b3a6ff7a0aae24ae03c0cf7
6
+ metadata.gz: 6c28ebe76d4529f674d91405bd2307e513ad6de767faa1a34bfc5ae64c600a95d49da1fe0b5dfb05067bfad64a651cc33816576b2d13efc341911332679af1d7
7
+ data.tar.gz: f7a12818f46e209751ede56e5d9cca3b8e36960690c166ef0104c09992de63a767ed03a29c0a9b921d98502c0e252e436476b985afbd12dd626873b09e2d3297
data/CHANGELOG.md CHANGED
@@ -5,6 +5,20 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [Unreleased]
9
+
10
+ ### Added
11
+
12
+ - **Dashboard UI**: Web-based dashboard for monitoring autoscaler events and status
13
+ - Overview page with real-time metrics, worker status, and recent events
14
+ - Workers page with detailed status, configuration, and cooldown info
15
+ - Events log with filtering by worker type
16
+ - Manual scale trigger from the UI
17
+ - **ScaleEvent model**: Tracks all scaling decisions in the database
18
+ - **Event recording configuration**: `record_events` and `record_all_events` options
19
+ - **New rake tasks**: `events`, `cleanup_events` for managing scale event history
20
+ - **Dashboard generator**: `rails generate solid_queue_heroku_autoscaler:dashboard`
21
+
8
22
  ## [0.1.0] - 2025-01-XX
9
23
 
10
24
  ### Added
data/README.md CHANGED
@@ -41,6 +41,27 @@ rails db:migrate
41
41
 
42
42
  This creates a `solid_queue_autoscaler_state` table to store cooldown timestamps.
43
43
 
44
+ ### Dashboard Setup (Optional)
45
+
46
+ For a web UI to monitor autoscaler events and status:
47
+
48
+ ```bash
49
+ rails generate solid_queue_heroku_autoscaler:dashboard
50
+ rails db:migrate
51
+ ```
52
+
53
+ Then mount the dashboard in `config/routes.rb`:
54
+
55
+ ```ruby
56
+ # With authentication (recommended)
57
+ authenticate :user, ->(u) { u.admin? } do
58
+ mount SolidQueueHerokuAutoscaler::Dashboard::Engine => "/autoscaler"
59
+ end
60
+
61
+ # Or without authentication
62
+ mount SolidQueueHerokuAutoscaler::Dashboard::Engine => "/autoscaler"
63
+ ```
64
+
44
65
  ## Quick Start
45
66
 
46
67
  ### Basic Configuration (Single Worker)
@@ -417,6 +438,64 @@ end
417
438
 
418
439
  In dry-run mode, all decisions are logged but no platform API calls are made.
419
440
 
441
+ ## Dashboard
442
+
443
+ The optional dashboard provides a web UI for monitoring the autoscaler:
444
+
445
+ ### Features
446
+
447
+ - **Overview Dashboard**: Real-time metrics, worker status, and recent events
448
+ - **Workers View**: Detailed status for each worker type with configuration and cooldowns
449
+ - **Events Log**: Historical record of all scaling decisions with filtering
450
+ - **Manual Scaling**: Trigger scale operations directly from the UI
451
+
452
+ ### Setup
453
+
454
+ 1. Generate the dashboard migration:
455
+
456
+ ```bash
457
+ rails generate solid_queue_heroku_autoscaler:dashboard
458
+ rails db:migrate
459
+ ```
460
+
461
+ 2. Mount the engine in `config/routes.rb`:
462
+
463
+ ```ruby
464
+ authenticate :user, ->(u) { u.admin? } do
465
+ mount SolidQueueHerokuAutoscaler::Dashboard::Engine => "/autoscaler"
466
+ end
467
+ ```
468
+
469
+ 3. Visit `/autoscaler` in your browser
470
+
471
+ ### Event Recording
472
+
473
+ By default, all scaling events are recorded to the database. Configure in your initializer:
474
+
475
+ ```ruby
476
+ SolidQueueHerokuAutoscaler.configure do |config|
477
+ # Record scale_up, scale_down, skipped, and error events (default: true)
478
+ config.record_events = true
479
+
480
+ # Also record no_change events (verbose, default: false)
481
+ config.record_all_events = false
482
+ end
483
+ ```
484
+
485
+ ### Rake Tasks for Events
486
+
487
+ ```bash
488
+ # View recent scale events
489
+ bundle exec rake solid_queue_autoscaler:events
490
+
491
+ # View events for a specific worker
492
+ WORKER=critical_worker bundle exec rake solid_queue_autoscaler:events
493
+
494
+ # Cleanup old events (default: keep 30 days)
495
+ bundle exec rake solid_queue_autoscaler:cleanup_events
496
+ KEEP_DAYS=7 bundle exec rake solid_queue_autoscaler:cleanup_events
497
+ ```
498
+
420
499
  ## Troubleshooting
421
500
 
422
501
  ### "Could not acquire advisory lock"
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rails/generators'
4
+ require 'rails/generators/active_record'
5
+
6
+ module SolidQueueHerokuAutoscaler
7
+ module Generators
8
+ # Generator for the dashboard migrations.
9
+ # Creates the scale events table for tracking autoscaler history.
10
+ #
11
+ # @example Run the generator
12
+ # rails generate solid_queue_heroku_autoscaler:dashboard
13
+ class DashboardGenerator < Rails::Generators::Base
14
+ include ActiveRecord::Generators::Migration
15
+
16
+ source_root File.expand_path('templates', __dir__)
17
+
18
+ desc 'Creates migrations for SolidQueueHerokuAutoscaler dashboard (events table)'
19
+
20
+ def create_migration_file
21
+ migration_template 'create_solid_queue_autoscaler_events.rb.erb',
22
+ 'db/migrate/create_solid_queue_autoscaler_events.rb'
23
+ end
24
+
25
+ def show_post_install
26
+ say ''
27
+ say '=== Solid Queue Autoscaler Dashboard Setup ==='
28
+ say ''
29
+ say 'Next steps:'
30
+ say ' 1. Run migrations: rails db:migrate'
31
+ say ' 2. Mount the dashboard in config/routes.rb:'
32
+ say ''
33
+ say ' mount SolidQueueHerokuAutoscaler::Dashboard::Engine => "/autoscaler"'
34
+ say ''
35
+ say ' 3. For authentication, wrap in a constraint:'
36
+ say ''
37
+ say ' authenticate :user, ->(u) { u.admin? } do'
38
+ say ' mount SolidQueueHerokuAutoscaler::Dashboard::Engine => "/autoscaler"'
39
+ say ' end'
40
+ say ''
41
+ say 'View the dashboard at: /autoscaler'
42
+ say ''
43
+ end
44
+
45
+ private
46
+
47
+ def migration_version
48
+ return unless defined?(ActiveRecord::VERSION)
49
+
50
+ "[#{ActiveRecord::VERSION::MAJOR}.#{ActiveRecord::VERSION::MINOR}]"
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ class CreateSolidQueueAutoscalerEvents < ActiveRecord::Migration<%= migration_version %>
4
+ def change
5
+ create_table :solid_queue_autoscaler_events do |t|
6
+ t.string :worker_name, null: false
7
+ t.string :action, null: false
8
+ t.integer :from_workers, null: false, default: 0
9
+ t.integer :to_workers, null: false, default: 0
10
+ t.text :reason
11
+ t.integer :queue_depth, default: 0
12
+ t.float :latency_seconds, default: 0.0
13
+ t.jsonb :metrics_json
14
+ t.boolean :dry_run, default: false
15
+
16
+ t.datetime :created_at, null: false
17
+ end
18
+
19
+ add_index :solid_queue_autoscaler_events, :worker_name
20
+ add_index :solid_queue_autoscaler_events, :action
21
+ add_index :solid_queue_autoscaler_events, :created_at
22
+ add_index :solid_queue_autoscaler_events, %i[worker_name created_at]
23
+ end
24
+ end
@@ -49,4 +49,10 @@ SolidQueueHerokuAutoscaler.configure do |config|
49
49
 
50
50
  # Optional: Custom logger
51
51
  # config.logger = Rails.logger
52
+
53
+ # Dashboard & Event Recording
54
+ # Record scale events to database for dashboard (requires dashboard migration)
55
+ config.record_events = true
56
+ # Also record no_change events (verbose, generates many records)
57
+ # config.record_all_events = false
52
58
  end
@@ -60,6 +60,9 @@ module SolidQueueHerokuAutoscaler
60
60
  attr_accessor :enabled, :logger
61
61
  attr_writer :lock_key
62
62
 
63
+ # Dashboard/event recording settings
64
+ attr_accessor :record_events, :record_all_events
65
+
63
66
  def initialize
64
67
  # Configuration name (auto-set when using named configurations)
65
68
  @name = :default
@@ -121,6 +124,10 @@ module SolidQueueHerokuAutoscaler
121
124
  @kubernetes_namespace = ENV['K8S_NAMESPACE'] || 'default'
122
125
  @kubernetes_context = ENV.fetch('K8S_CONTEXT', nil)
123
126
  @kubernetes_kubeconfig = ENV.fetch('KUBECONFIG', nil)
127
+
128
+ # Dashboard/event recording settings
129
+ @record_events = true # Record scale events to database
130
+ @record_all_events = false # Also record no_change events (verbose)
124
131
  end
125
132
 
126
133
  # Returns the lock key, auto-generating based on name if not explicitly set
@@ -186,6 +193,23 @@ module SolidQueueHerokuAutoscaler
186
193
  enabled
187
194
  end
188
195
 
196
+ def record_events?
197
+ record_events && connection_available?
198
+ end
199
+
200
+ def record_all_events?
201
+ record_all_events && record_events?
202
+ end
203
+
204
+ def connection_available?
205
+ return true if database_connection
206
+ return false unless defined?(ActiveRecord::Base)
207
+
208
+ ActiveRecord::Base.connected?
209
+ rescue StandardError
210
+ false
211
+ end
212
+
189
213
  # Returns the configured adapter instance.
190
214
  # Creates a new instance from adapter_class if not set.
191
215
  # Defaults to Heroku adapter.
@@ -0,0 +1,136 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'action_controller/railtie'
4
+ require 'action_view/railtie'
5
+
6
+ module SolidQueueHerokuAutoscaler
7
+ module Dashboard
8
+ # Rails engine that provides the autoscaler dashboard.
9
+ # Mount at /solid_queue_autoscaler or integrate with Mission Control.
10
+ #
11
+ # @example Mount in routes.rb
12
+ # mount SolidQueueHerokuAutoscaler::Dashboard::Engine => "/solid_queue_autoscaler"
13
+ #
14
+ # @example With authentication
15
+ # authenticate :user, ->(u) { u.admin? } do
16
+ # mount SolidQueueHerokuAutoscaler::Dashboard::Engine => "/solid_queue_autoscaler"
17
+ # end
18
+ class Engine < ::Rails::Engine
19
+ isolate_namespace SolidQueueHerokuAutoscaler::Dashboard
20
+
21
+ # Engine configuration
22
+ config.solid_queue_autoscaler_dashboard = ActiveSupport::OrderedOptions.new
23
+ config.solid_queue_autoscaler_dashboard.title = 'Solid Queue Autoscaler'
24
+
25
+ # Configure view paths
26
+ config.paths['app/views'] = File.expand_path('views', __dir__)
27
+
28
+ initializer 'solid_queue_autoscaler.dashboard.view_paths' do
29
+ ActiveSupport.on_load(:action_controller) do
30
+ append_view_path File.expand_path('views', __dir__)
31
+ end
32
+ end
33
+
34
+ initializer 'solid_queue_autoscaler.dashboard.integration' do
35
+ # Auto-integrate with Mission Control if available
36
+ ActiveSupport.on_load(:mission_control) do
37
+ # Register with Mission Control's tab system if available
38
+ end
39
+ end
40
+ end
41
+
42
+ # Application controller for dashboard
43
+ class ApplicationController < ActionController::Base
44
+ protect_from_forgery with: :exception
45
+
46
+ layout 'solid_queue_heroku_autoscaler/dashboard/application'
47
+
48
+ private
49
+
50
+ def autoscaler_status
51
+ @autoscaler_status ||= SolidQueueHerokuAutoscaler::Dashboard.status
52
+ end
53
+ helper_method :autoscaler_status
54
+
55
+ def events_available?
56
+ @events_available ||= SolidQueueHerokuAutoscaler::Dashboard.events_table_available?
57
+ end
58
+ helper_method :events_available?
59
+ end
60
+
61
+ # Main dashboard controller
62
+ class DashboardController < ApplicationController
63
+ def index
64
+ @status = autoscaler_status
65
+ @stats = SolidQueueHerokuAutoscaler::Dashboard.event_stats(since: 24.hours.ago)
66
+ @recent_events = SolidQueueHerokuAutoscaler::Dashboard.recent_events(limit: 10)
67
+ end
68
+ end
69
+
70
+ # Events controller
71
+ class EventsController < ApplicationController
72
+ def index
73
+ @worker_filter = params[:worker]
74
+ @events = SolidQueueHerokuAutoscaler::Dashboard.recent_events(
75
+ limit: params.fetch(:limit, 100).to_i,
76
+ worker_name: @worker_filter
77
+ )
78
+ @stats = SolidQueueHerokuAutoscaler::Dashboard.event_stats(
79
+ since: 24.hours.ago,
80
+ worker_name: @worker_filter
81
+ )
82
+ end
83
+ end
84
+
85
+ # Workers controller
86
+ class WorkersController < ApplicationController
87
+ def index
88
+ @workers = autoscaler_status
89
+ end
90
+
91
+ def show
92
+ worker_name = params[:id].to_sym
93
+ @worker = SolidQueueHerokuAutoscaler::Dashboard.worker_status(worker_name)
94
+ @events = SolidQueueHerokuAutoscaler::Dashboard.recent_events(
95
+ limit: 20,
96
+ worker_name: worker_name.to_s
97
+ )
98
+ end
99
+
100
+ def scale
101
+ worker_name = params[:id].to_sym
102
+ @result = SolidQueueHerokuAutoscaler.scale!(worker_name)
103
+ redirect_to worker_path(worker_name), notice: scale_notice(@result)
104
+ end
105
+
106
+ private
107
+
108
+ def scale_notice(result)
109
+ if result.success?
110
+ if result.scaled?
111
+ "Scaled from #{result.decision.from} to #{result.decision.to} workers"
112
+ elsif result.skipped?
113
+ "Skipped: #{result.skipped_reason}"
114
+ else
115
+ "No change needed: #{result.decision&.reason}"
116
+ end
117
+ else
118
+ "Error: #{result.error}"
119
+ end
120
+ end
121
+ end
122
+ end
123
+ end
124
+
125
+ # Define routes for the engine
126
+ SolidQueueHerokuAutoscaler::Dashboard::Engine.routes.draw do
127
+ root to: 'dashboard#index'
128
+
129
+ resources :events, only: [:index]
130
+
131
+ resources :workers, only: %i[index show] do
132
+ member do
133
+ post :scale
134
+ end
135
+ end
136
+ end
@@ -0,0 +1,206 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <title>Solid Queue Autoscaler</title>
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <%= csrf_meta_tags %>
7
+ <style>
8
+ :root {
9
+ --bg-primary: #1a1a2e;
10
+ --bg-secondary: #16213e;
11
+ --bg-card: #0f3460;
12
+ --text-primary: #eee;
13
+ --text-secondary: #aaa;
14
+ --accent: #e94560;
15
+ --success: #00d26a;
16
+ --warning: #ffbe0b;
17
+ --info: #00b4d8;
18
+ --border: #333;
19
+ }
20
+ * { box-sizing: border-box; margin: 0; padding: 0; }
21
+ body {
22
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
23
+ background: var(--bg-primary);
24
+ color: var(--text-primary);
25
+ line-height: 1.6;
26
+ }
27
+ .container { max-width: 1400px; margin: 0 auto; padding: 20px; }
28
+ header {
29
+ background: var(--bg-secondary);
30
+ padding: 15px 0;
31
+ border-bottom: 1px solid var(--border);
32
+ margin-bottom: 30px;
33
+ }
34
+ header .container {
35
+ display: flex;
36
+ justify-content: space-between;
37
+ align-items: center;
38
+ }
39
+ header h1 {
40
+ font-size: 1.5rem;
41
+ display: flex;
42
+ align-items: center;
43
+ gap: 10px;
44
+ }
45
+ header h1 span { color: var(--accent); }
46
+ nav { display: flex; gap: 20px; }
47
+ nav a {
48
+ color: var(--text-secondary);
49
+ text-decoration: none;
50
+ padding: 8px 16px;
51
+ border-radius: 6px;
52
+ transition: all 0.2s;
53
+ }
54
+ nav a:hover, nav a.active {
55
+ color: var(--text-primary);
56
+ background: var(--bg-card);
57
+ }
58
+ .flash {
59
+ padding: 12px 20px;
60
+ border-radius: 6px;
61
+ margin-bottom: 20px;
62
+ }
63
+ .flash.notice { background: rgba(0, 210, 106, 0.2); border: 1px solid var(--success); }
64
+ .flash.alert { background: rgba(233, 69, 96, 0.2); border: 1px solid var(--accent); }
65
+ .grid { display: grid; gap: 20px; }
66
+ .grid-2 { grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); }
67
+ .grid-3 { grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); }
68
+ .grid-4 { grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); }
69
+ .card {
70
+ background: var(--bg-card);
71
+ border-radius: 12px;
72
+ padding: 20px;
73
+ border: 1px solid var(--border);
74
+ }
75
+ .card h2, .card h3 {
76
+ margin-bottom: 15px;
77
+ display: flex;
78
+ align-items: center;
79
+ gap: 10px;
80
+ }
81
+ .stat-card {
82
+ text-align: center;
83
+ padding: 25px;
84
+ }
85
+ .stat-card .value {
86
+ font-size: 2.5rem;
87
+ font-weight: bold;
88
+ color: var(--accent);
89
+ }
90
+ .stat-card .label {
91
+ color: var(--text-secondary);
92
+ font-size: 0.9rem;
93
+ margin-top: 5px;
94
+ }
95
+ .stat-card.success .value { color: var(--success); }
96
+ .stat-card.warning .value { color: var(--warning); }
97
+ .stat-card.info .value { color: var(--info); }
98
+ table {
99
+ width: 100%;
100
+ border-collapse: collapse;
101
+ }
102
+ th, td {
103
+ padding: 12px;
104
+ text-align: left;
105
+ border-bottom: 1px solid var(--border);
106
+ }
107
+ th {
108
+ color: var(--text-secondary);
109
+ font-weight: 500;
110
+ font-size: 0.85rem;
111
+ text-transform: uppercase;
112
+ }
113
+ tr:hover { background: rgba(255,255,255,0.02); }
114
+ .badge {
115
+ display: inline-block;
116
+ padding: 4px 10px;
117
+ border-radius: 20px;
118
+ font-size: 0.8rem;
119
+ font-weight: 500;
120
+ }
121
+ .badge-success { background: rgba(0,210,106,0.2); color: var(--success); }
122
+ .badge-warning { background: rgba(255,190,11,0.2); color: var(--warning); }
123
+ .badge-danger { background: rgba(233,69,96,0.2); color: var(--accent); }
124
+ .badge-info { background: rgba(0,180,216,0.2); color: var(--info); }
125
+ .badge-neutral { background: rgba(170,170,170,0.2); color: var(--text-secondary); }
126
+ .btn {
127
+ display: inline-block;
128
+ padding: 10px 20px;
129
+ border-radius: 6px;
130
+ border: none;
131
+ cursor: pointer;
132
+ font-size: 0.9rem;
133
+ text-decoration: none;
134
+ transition: all 0.2s;
135
+ }
136
+ .btn-primary {
137
+ background: var(--accent);
138
+ color: white;
139
+ }
140
+ .btn-primary:hover { background: #d63654; }
141
+ .btn-secondary {
142
+ background: var(--bg-secondary);
143
+ color: var(--text-primary);
144
+ border: 1px solid var(--border);
145
+ }
146
+ .btn-secondary:hover { background: var(--bg-card); }
147
+ .btn-sm { padding: 6px 12px; font-size: 0.8rem; }
148
+ .progress-bar {
149
+ height: 8px;
150
+ background: var(--bg-secondary);
151
+ border-radius: 4px;
152
+ overflow: hidden;
153
+ margin-top: 8px;
154
+ }
155
+ .progress-bar .fill {
156
+ height: 100%;
157
+ border-radius: 4px;
158
+ transition: width 0.3s;
159
+ }
160
+ .text-muted { color: var(--text-secondary); }
161
+ .text-success { color: var(--success); }
162
+ .text-warning { color: var(--warning); }
163
+ .text-danger { color: var(--accent); }
164
+ .text-info { color: var(--info); }
165
+ .mb-1 { margin-bottom: 0.5rem; }
166
+ .mb-2 { margin-bottom: 1rem; }
167
+ .mb-3 { margin-bottom: 1.5rem; }
168
+ .mt-2 { margin-top: 1rem; }
169
+ .d-flex { display: flex; }
170
+ .justify-between { justify-content: space-between; }
171
+ .align-center { align-items: center; }
172
+ .gap-2 { gap: 1rem; }
173
+ .time-ago { font-size: 0.85rem; color: var(--text-secondary); }
174
+ </style>
175
+ </head>
176
+ <body>
177
+ <header>
178
+ <div class="container">
179
+ <h1>
180
+ <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
181
+ <path d="M12 2L2 7l10 5 10-5-10-5z"/>
182
+ <path d="M2 17l10 5 10-5"/>
183
+ <path d="M2 12l10 5 10-5"/>
184
+ </svg>
185
+ <span>Solid Queue</span> Autoscaler
186
+ </h1>
187
+ <nav>
188
+ <%= link_to 'Dashboard', main_app.respond_to?(:solid_queue_autoscaler_path) ? main_app.solid_queue_autoscaler_path : root_path, class: request.path == root_path ? 'active' : '' %>
189
+ <%= link_to 'Workers', workers_path, class: request.path.include?('/workers') ? 'active' : '' %>
190
+ <%= link_to 'Events', events_path, class: request.path.include?('/events') ? 'active' : '' %>
191
+ </nav>
192
+ </div>
193
+ </header>
194
+
195
+ <main class="container">
196
+ <% if notice %>
197
+ <div class="flash notice"><%= notice %></div>
198
+ <% end %>
199
+ <% if alert %>
200
+ <div class="flash alert"><%= alert %></div>
201
+ <% end %>
202
+
203
+ <%= yield %>
204
+ </main>
205
+ </body>
206
+ </html>