solid_queue_heroku_autoscaler 0.1.0 → 0.2.1
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 +4 -4
- data/CHANGELOG.md +27 -1
- data/README.md +79 -0
- data/lib/generators/solid_queue_heroku_autoscaler/dashboard_generator.rb +54 -0
- data/lib/generators/solid_queue_heroku_autoscaler/templates/create_solid_queue_autoscaler_events.rb.erb +24 -0
- data/lib/generators/solid_queue_heroku_autoscaler/templates/initializer.rb +6 -0
- data/lib/solid_queue_heroku_autoscaler/configuration.rb +54 -2
- data/lib/solid_queue_heroku_autoscaler/dashboard/engine.rb +136 -0
- data/lib/solid_queue_heroku_autoscaler/dashboard/views/layouts/solid_queue_heroku_autoscaler/dashboard/application.html.erb +206 -0
- data/lib/solid_queue_heroku_autoscaler/dashboard/views/solid_queue_heroku_autoscaler/dashboard/dashboard/index.html.erb +138 -0
- data/lib/solid_queue_heroku_autoscaler/dashboard/views/solid_queue_heroku_autoscaler/dashboard/events/index.html.erb +102 -0
- data/lib/solid_queue_heroku_autoscaler/dashboard/views/solid_queue_heroku_autoscaler/dashboard/workers/index.html.erb +106 -0
- data/lib/solid_queue_heroku_autoscaler/dashboard/views/solid_queue_heroku_autoscaler/dashboard/workers/show.html.erb +209 -0
- data/lib/solid_queue_heroku_autoscaler/dashboard.rb +99 -0
- data/lib/solid_queue_heroku_autoscaler/railtie.rb +31 -1
- data/lib/solid_queue_heroku_autoscaler/scale_event.rb +292 -0
- data/lib/solid_queue_heroku_autoscaler/scaler.rb +67 -0
- data/lib/solid_queue_heroku_autoscaler/version.rb +1 -1
- data/lib/solid_queue_heroku_autoscaler.rb +2 -0
- metadata +12 -2
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
<h2 class="mb-3">Dashboard Overview</h2>
|
|
2
|
+
|
|
3
|
+
<!-- Stats Row -->
|
|
4
|
+
<div class="grid grid-4 mb-3">
|
|
5
|
+
<div class="card stat-card info">
|
|
6
|
+
<div class="value"><%= @status.values.sum { |w| w[:current_workers] } %></div>
|
|
7
|
+
<div class="label">Total Workers</div>
|
|
8
|
+
</div>
|
|
9
|
+
<div class="card stat-card">
|
|
10
|
+
<div class="value"><%= @status.values.sum { |w| w[:metrics][:queue_depth] } %></div>
|
|
11
|
+
<div class="label">Queue Depth</div>
|
|
12
|
+
</div>
|
|
13
|
+
<div class="card stat-card success">
|
|
14
|
+
<div class="value"><%= @stats[:scale_up_count] || 0 %></div>
|
|
15
|
+
<div class="label">Scale Ups (24h)</div>
|
|
16
|
+
</div>
|
|
17
|
+
<div class="card stat-card warning">
|
|
18
|
+
<div class="value"><%= @stats[:scale_down_count] || 0 %></div>
|
|
19
|
+
<div class="label">Scale Downs (24h)</div>
|
|
20
|
+
</div>
|
|
21
|
+
</div>
|
|
22
|
+
|
|
23
|
+
<!-- Workers Overview -->
|
|
24
|
+
<div class="grid grid-2 mb-3">
|
|
25
|
+
<% @status.each do |name, worker| %>
|
|
26
|
+
<div class="card">
|
|
27
|
+
<h3>
|
|
28
|
+
<% if worker[:enabled] %>
|
|
29
|
+
<span class="badge badge-success">Active</span>
|
|
30
|
+
<% else %>
|
|
31
|
+
<span class="badge badge-neutral">Disabled</span>
|
|
32
|
+
<% end %>
|
|
33
|
+
<%= name %>
|
|
34
|
+
<% if worker[:dry_run] %>
|
|
35
|
+
<span class="badge badge-warning">Dry Run</span>
|
|
36
|
+
<% end %>
|
|
37
|
+
</h3>
|
|
38
|
+
|
|
39
|
+
<div class="d-flex justify-between mb-2">
|
|
40
|
+
<div>
|
|
41
|
+
<strong><%= worker[:current_workers] %></strong> / <%= worker[:max_workers] %> workers
|
|
42
|
+
<div class="progress-bar" style="width: 150px;">
|
|
43
|
+
<% pct = (worker[:current_workers].to_f / worker[:max_workers] * 100).round %>
|
|
44
|
+
<div class="fill" style="width: <%= pct %>%; background: <%= pct > 80 ? 'var(--warning)' : 'var(--success)' %>;"></div>
|
|
45
|
+
</div>
|
|
46
|
+
</div>
|
|
47
|
+
<div class="text-muted">
|
|
48
|
+
Process: <strong><%= worker[:process_type] %></strong>
|
|
49
|
+
</div>
|
|
50
|
+
</div>
|
|
51
|
+
|
|
52
|
+
<div class="grid grid-3 mt-2">
|
|
53
|
+
<div>
|
|
54
|
+
<div class="text-muted">Queue Depth</div>
|
|
55
|
+
<strong class="<%= worker[:metrics][:queue_depth] > worker[:thresholds][:scale_up_queue_depth] ? 'text-danger' : '' %>">
|
|
56
|
+
<%= worker[:metrics][:queue_depth] %>
|
|
57
|
+
</strong>
|
|
58
|
+
</div>
|
|
59
|
+
<div>
|
|
60
|
+
<div class="text-muted">Latency</div>
|
|
61
|
+
<strong class="<%= worker[:metrics][:latency_seconds] > worker[:thresholds][:scale_up_latency] ? 'text-danger' : '' %>">
|
|
62
|
+
<%= worker[:metrics][:latency_seconds].round %>s
|
|
63
|
+
</strong>
|
|
64
|
+
</div>
|
|
65
|
+
<div>
|
|
66
|
+
<div class="text-muted">Jobs/min</div>
|
|
67
|
+
<strong><%= worker[:metrics][:jobs_per_minute] %></strong>
|
|
68
|
+
</div>
|
|
69
|
+
</div>
|
|
70
|
+
|
|
71
|
+
<div class="mt-2">
|
|
72
|
+
<%= link_to 'View Details', worker_path(name), class: 'btn btn-secondary btn-sm' %>
|
|
73
|
+
</div>
|
|
74
|
+
</div>
|
|
75
|
+
<% end %>
|
|
76
|
+
</div>
|
|
77
|
+
|
|
78
|
+
<!-- Recent Events -->
|
|
79
|
+
<div class="card">
|
|
80
|
+
<div class="d-flex justify-between align-center mb-2">
|
|
81
|
+
<h3>Recent Scale Events</h3>
|
|
82
|
+
<%= link_to 'View All', events_path, class: 'btn btn-secondary btn-sm' %>
|
|
83
|
+
</div>
|
|
84
|
+
|
|
85
|
+
<% if !events_available? %>
|
|
86
|
+
<p class="text-muted">
|
|
87
|
+
Events table not found. Run:
|
|
88
|
+
<code>rails generate solid_queue_heroku_autoscaler:dashboard</code>
|
|
89
|
+
</p>
|
|
90
|
+
<% elsif @recent_events.empty? %>
|
|
91
|
+
<p class="text-muted">No scale events recorded yet.</p>
|
|
92
|
+
<% else %>
|
|
93
|
+
<table>
|
|
94
|
+
<thead>
|
|
95
|
+
<tr>
|
|
96
|
+
<th>Time</th>
|
|
97
|
+
<th>Worker</th>
|
|
98
|
+
<th>Action</th>
|
|
99
|
+
<th>Workers</th>
|
|
100
|
+
<th>Reason</th>
|
|
101
|
+
</tr>
|
|
102
|
+
</thead>
|
|
103
|
+
<tbody>
|
|
104
|
+
<% @recent_events.each do |event| %>
|
|
105
|
+
<tr>
|
|
106
|
+
<td class="time-ago"><%= event.created_at.strftime('%H:%M:%S') %></td>
|
|
107
|
+
<td><%= event.worker_name %></td>
|
|
108
|
+
<td>
|
|
109
|
+
<% case event.action %>
|
|
110
|
+
<% when 'scale_up' %>
|
|
111
|
+
<span class="badge badge-success">↑ Scale Up</span>
|
|
112
|
+
<% when 'scale_down' %>
|
|
113
|
+
<span class="badge badge-warning">↓ Scale Down</span>
|
|
114
|
+
<% when 'no_change' %>
|
|
115
|
+
<span class="badge badge-neutral">— No Change</span>
|
|
116
|
+
<% when 'skipped' %>
|
|
117
|
+
<span class="badge badge-info">⏭ Skipped</span>
|
|
118
|
+
<% when 'error' %>
|
|
119
|
+
<span class="badge badge-danger">✕ Error</span>
|
|
120
|
+
<% end %>
|
|
121
|
+
<% if event.dry_run %>
|
|
122
|
+
<span class="badge badge-neutral">DRY</span>
|
|
123
|
+
<% end %>
|
|
124
|
+
</td>
|
|
125
|
+
<td>
|
|
126
|
+
<% if event.scaled? %>
|
|
127
|
+
<%= event.from_workers %> → <%= event.to_workers %>
|
|
128
|
+
<% else %>
|
|
129
|
+
<%= event.from_workers %>
|
|
130
|
+
<% end %>
|
|
131
|
+
</td>
|
|
132
|
+
<td class="text-muted"><%= truncate(event.reason, length: 60) %></td>
|
|
133
|
+
</tr>
|
|
134
|
+
<% end %>
|
|
135
|
+
</tbody>
|
|
136
|
+
</table>
|
|
137
|
+
<% end %>
|
|
138
|
+
</div>
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
<h2 class="mb-3">Scale Events</h2>
|
|
2
|
+
|
|
3
|
+
<!-- Filter -->
|
|
4
|
+
<div class="card mb-3">
|
|
5
|
+
<div class="d-flex gap-2 align-center">
|
|
6
|
+
<span class="text-muted">Filter by worker:</span>
|
|
7
|
+
<%= link_to 'All', events_path, class: "btn btn-sm #{@worker_filter.nil? ? 'btn-primary' : 'btn-secondary'}" %>
|
|
8
|
+
<% autoscaler_status.keys.each do |name| %>
|
|
9
|
+
<%= link_to name, events_path(worker: name), class: "btn btn-sm #{@worker_filter == name.to_s ? 'btn-primary' : 'btn-secondary'}" %>
|
|
10
|
+
<% end %>
|
|
11
|
+
</div>
|
|
12
|
+
</div>
|
|
13
|
+
|
|
14
|
+
<!-- Stats -->
|
|
15
|
+
<div class="grid grid-4 mb-3">
|
|
16
|
+
<div class="card stat-card">
|
|
17
|
+
<div class="value"><%= @stats[:total] %></div>
|
|
18
|
+
<div class="label">Total Events (24h)</div>
|
|
19
|
+
</div>
|
|
20
|
+
<div class="card stat-card success">
|
|
21
|
+
<div class="value"><%= @stats[:scale_up_count] || 0 %></div>
|
|
22
|
+
<div class="label">Scale Ups</div>
|
|
23
|
+
</div>
|
|
24
|
+
<div class="card stat-card warning">
|
|
25
|
+
<div class="value"><%= @stats[:scale_down_count] || 0 %></div>
|
|
26
|
+
<div class="label">Scale Downs</div>
|
|
27
|
+
</div>
|
|
28
|
+
<div class="card stat-card">
|
|
29
|
+
<div class="value"><%= (@stats[:avg_latency] || 0).round %>s</div>
|
|
30
|
+
<div class="label">Avg Latency</div>
|
|
31
|
+
</div>
|
|
32
|
+
</div>
|
|
33
|
+
|
|
34
|
+
<!-- Events Table -->
|
|
35
|
+
<div class="card">
|
|
36
|
+
<% if !events_available? %>
|
|
37
|
+
<p class="text-muted">
|
|
38
|
+
Events table not found. Run:
|
|
39
|
+
<code>rails generate solid_queue_heroku_autoscaler:dashboard</code>
|
|
40
|
+
</p>
|
|
41
|
+
<% elsif @events.empty? %>
|
|
42
|
+
<p class="text-muted">No scale events recorded yet.</p>
|
|
43
|
+
<% else %>
|
|
44
|
+
<table>
|
|
45
|
+
<thead>
|
|
46
|
+
<tr>
|
|
47
|
+
<th>Time</th>
|
|
48
|
+
<th>Worker</th>
|
|
49
|
+
<th>Action</th>
|
|
50
|
+
<th>Workers</th>
|
|
51
|
+
<th>Queue Depth</th>
|
|
52
|
+
<th>Latency</th>
|
|
53
|
+
<th>Reason</th>
|
|
54
|
+
</tr>
|
|
55
|
+
</thead>
|
|
56
|
+
<tbody>
|
|
57
|
+
<% @events.each do |event| %>
|
|
58
|
+
<tr>
|
|
59
|
+
<td>
|
|
60
|
+
<div><%= event.created_at.strftime('%Y-%m-%d') %></div>
|
|
61
|
+
<div class="time-ago"><%= event.created_at.strftime('%H:%M:%S') %></div>
|
|
62
|
+
</td>
|
|
63
|
+
<td>
|
|
64
|
+
<%= link_to event.worker_name, worker_path(event.worker_name) %>
|
|
65
|
+
</td>
|
|
66
|
+
<td>
|
|
67
|
+
<% case event.action %>
|
|
68
|
+
<% when 'scale_up' %>
|
|
69
|
+
<span class="badge badge-success">↑ Scale Up</span>
|
|
70
|
+
<% when 'scale_down' %>
|
|
71
|
+
<span class="badge badge-warning">↓ Scale Down</span>
|
|
72
|
+
<% when 'no_change' %>
|
|
73
|
+
<span class="badge badge-neutral">— No Change</span>
|
|
74
|
+
<% when 'skipped' %>
|
|
75
|
+
<span class="badge badge-info">⏭ Skipped</span>
|
|
76
|
+
<% when 'error' %>
|
|
77
|
+
<span class="badge badge-danger">✕ Error</span>
|
|
78
|
+
<% end %>
|
|
79
|
+
<% if event.dry_run %>
|
|
80
|
+
<span class="badge badge-neutral">DRY</span>
|
|
81
|
+
<% end %>
|
|
82
|
+
</td>
|
|
83
|
+
<td>
|
|
84
|
+
<% if event.scaled? %>
|
|
85
|
+
<strong><%= event.from_workers %></strong> → <strong><%= event.to_workers %></strong>
|
|
86
|
+
<% delta = event.to_workers - event.from_workers %>
|
|
87
|
+
<span class="<%= delta > 0 ? 'text-success' : 'text-warning' %>">
|
|
88
|
+
(<%= delta > 0 ? '+' : '' %><%= delta %>)
|
|
89
|
+
</span>
|
|
90
|
+
<% else %>
|
|
91
|
+
<%= event.from_workers %>
|
|
92
|
+
<% end %>
|
|
93
|
+
</td>
|
|
94
|
+
<td><%= event.queue_depth %></td>
|
|
95
|
+
<td><%= event.latency_seconds.round %>s</td>
|
|
96
|
+
<td class="text-muted" style="max-width: 300px;"><%= event.reason %></td>
|
|
97
|
+
</tr>
|
|
98
|
+
<% end %>
|
|
99
|
+
</tbody>
|
|
100
|
+
</table>
|
|
101
|
+
<% end %>
|
|
102
|
+
</div>
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
<h2 class="mb-3">Workers</h2>
|
|
2
|
+
|
|
3
|
+
<div class="grid grid-2">
|
|
4
|
+
<% @workers.each do |name, worker| %>
|
|
5
|
+
<div class="card">
|
|
6
|
+
<h3>
|
|
7
|
+
<% if worker[:enabled] %>
|
|
8
|
+
<span class="badge badge-success">Active</span>
|
|
9
|
+
<% else %>
|
|
10
|
+
<span class="badge badge-neutral">Disabled</span>
|
|
11
|
+
<% end %>
|
|
12
|
+
<%= name %>
|
|
13
|
+
<% if worker[:dry_run] %>
|
|
14
|
+
<span class="badge badge-warning">Dry Run</span>
|
|
15
|
+
<% end %>
|
|
16
|
+
</h3>
|
|
17
|
+
|
|
18
|
+
<!-- Worker Status -->
|
|
19
|
+
<div class="d-flex justify-between mb-2">
|
|
20
|
+
<div>
|
|
21
|
+
<strong class="text-info" style="font-size: 1.5rem;"><%= worker[:current_workers] %></strong>
|
|
22
|
+
<span class="text-muted">/ <%= worker[:max_workers] %> workers</span>
|
|
23
|
+
<div class="progress-bar" style="width: 200px;">
|
|
24
|
+
<% pct = (worker[:current_workers].to_f / worker[:max_workers] * 100).round %>
|
|
25
|
+
<div class="fill" style="width: <%= pct %>%; background: <%= pct > 80 ? 'var(--warning)' : 'var(--success)' %>;"></div>
|
|
26
|
+
</div>
|
|
27
|
+
</div>
|
|
28
|
+
</div>
|
|
29
|
+
|
|
30
|
+
<!-- Configuration -->
|
|
31
|
+
<div class="mb-2">
|
|
32
|
+
<div class="text-muted mb-1">Configuration</div>
|
|
33
|
+
<table style="width: auto;">
|
|
34
|
+
<tr>
|
|
35
|
+
<td class="text-muted" style="padding: 4px 12px 4px 0;">Process Type</td>
|
|
36
|
+
<td><code><%= worker[:process_type] %></code></td>
|
|
37
|
+
</tr>
|
|
38
|
+
<tr>
|
|
39
|
+
<td class="text-muted" style="padding: 4px 12px 4px 0;">Queues</td>
|
|
40
|
+
<td><code><%= Array(worker[:queues]).join(', ') %></code></td>
|
|
41
|
+
</tr>
|
|
42
|
+
<tr>
|
|
43
|
+
<td class="text-muted" style="padding: 4px 12px 4px 0;">Strategy</td>
|
|
44
|
+
<td><code><%= worker[:scaling_strategy] %></code></td>
|
|
45
|
+
</tr>
|
|
46
|
+
<tr>
|
|
47
|
+
<td class="text-muted" style="padding: 4px 12px 4px 0;">Workers Range</td>
|
|
48
|
+
<td><%= worker[:min_workers] %> - <%= worker[:max_workers] %></td>
|
|
49
|
+
</tr>
|
|
50
|
+
</table>
|
|
51
|
+
</div>
|
|
52
|
+
|
|
53
|
+
<!-- Metrics -->
|
|
54
|
+
<div class="grid grid-3 mb-2">
|
|
55
|
+
<div>
|
|
56
|
+
<div class="text-muted">Queue Depth</div>
|
|
57
|
+
<strong class="<%= worker[:metrics][:queue_depth] > worker[:thresholds][:scale_up_queue_depth] ? 'text-danger' : '' %>">
|
|
58
|
+
<%= worker[:metrics][:queue_depth] %>
|
|
59
|
+
</strong>
|
|
60
|
+
<div class="text-muted" style="font-size: 0.8rem;">
|
|
61
|
+
threshold: <%= worker[:thresholds][:scale_up_queue_depth] %>
|
|
62
|
+
</div>
|
|
63
|
+
</div>
|
|
64
|
+
<div>
|
|
65
|
+
<div class="text-muted">Latency</div>
|
|
66
|
+
<strong class="<%= worker[:metrics][:latency_seconds] > worker[:thresholds][:scale_up_latency] ? 'text-danger' : '' %>">
|
|
67
|
+
<%= worker[:metrics][:latency_seconds].round %>s
|
|
68
|
+
</strong>
|
|
69
|
+
<div class="text-muted" style="font-size: 0.8rem;">
|
|
70
|
+
threshold: <%= worker[:thresholds][:scale_up_latency] %>s
|
|
71
|
+
</div>
|
|
72
|
+
</div>
|
|
73
|
+
<div>
|
|
74
|
+
<div class="text-muted">Jobs/min</div>
|
|
75
|
+
<strong><%= worker[:metrics][:jobs_per_minute] %></strong>
|
|
76
|
+
</div>
|
|
77
|
+
</div>
|
|
78
|
+
|
|
79
|
+
<!-- Cooldowns -->
|
|
80
|
+
<div class="mb-2">
|
|
81
|
+
<div class="text-muted mb-1">Cooldowns</div>
|
|
82
|
+
<div class="d-flex gap-2">
|
|
83
|
+
<% if worker[:cooldowns][:scale_up_remaining] > 0 %>
|
|
84
|
+
<span class="badge badge-info">
|
|
85
|
+
Scale Up: <%= worker[:cooldowns][:scale_up_remaining] %>s
|
|
86
|
+
</span>
|
|
87
|
+
<% end %>
|
|
88
|
+
<% if worker[:cooldowns][:scale_down_remaining] > 0 %>
|
|
89
|
+
<span class="badge badge-warning">
|
|
90
|
+
Scale Down: <%= worker[:cooldowns][:scale_down_remaining] %>s
|
|
91
|
+
</span>
|
|
92
|
+
<% end %>
|
|
93
|
+
<% if worker[:cooldowns][:scale_up_remaining] == 0 && worker[:cooldowns][:scale_down_remaining] == 0 %>
|
|
94
|
+
<span class="text-muted">No active cooldowns</span>
|
|
95
|
+
<% end %>
|
|
96
|
+
</div>
|
|
97
|
+
</div>
|
|
98
|
+
|
|
99
|
+
<!-- Actions -->
|
|
100
|
+
<div class="d-flex gap-2">
|
|
101
|
+
<%= link_to 'View Details', worker_path(name), class: 'btn btn-secondary btn-sm' %>
|
|
102
|
+
<%= button_to 'Scale Now', scale_worker_path(name), method: :post, class: 'btn btn-primary btn-sm', disabled: !worker[:enabled] %>
|
|
103
|
+
</div>
|
|
104
|
+
</div>
|
|
105
|
+
<% end %>
|
|
106
|
+
</div>
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
<div class="d-flex justify-between align-center mb-3">
|
|
2
|
+
<h2>
|
|
3
|
+
Worker: <%= @worker[:name] %>
|
|
4
|
+
<% if @worker[:enabled] %>
|
|
5
|
+
<span class="badge badge-success">Active</span>
|
|
6
|
+
<% else %>
|
|
7
|
+
<span class="badge badge-neutral">Disabled</span>
|
|
8
|
+
<% end %>
|
|
9
|
+
<% if @worker[:dry_run] %>
|
|
10
|
+
<span class="badge badge-warning">Dry Run</span>
|
|
11
|
+
<% end %>
|
|
12
|
+
</h2>
|
|
13
|
+
<div class="d-flex gap-2">
|
|
14
|
+
<%= link_to '← Back', workers_path, class: 'btn btn-secondary btn-sm' %>
|
|
15
|
+
<%= button_to 'Scale Now', scale_worker_path(@worker[:name]), method: :post, class: 'btn btn-primary btn-sm', disabled: !@worker[:enabled] %>
|
|
16
|
+
</div>
|
|
17
|
+
</div>
|
|
18
|
+
|
|
19
|
+
<div class="grid grid-2 mb-3">
|
|
20
|
+
<!-- Current Status -->
|
|
21
|
+
<div class="card">
|
|
22
|
+
<h3>Current Status</h3>
|
|
23
|
+
|
|
24
|
+
<div class="d-flex justify-between align-center mb-2">
|
|
25
|
+
<div>
|
|
26
|
+
<strong class="text-info" style="font-size: 2rem;"><%= @worker[:current_workers] %></strong>
|
|
27
|
+
<span class="text-muted">/ <%= @worker[:max_workers] %> workers</span>
|
|
28
|
+
</div>
|
|
29
|
+
<div class="text-muted">
|
|
30
|
+
Min: <%= @worker[:min_workers] %> | Max: <%= @worker[:max_workers] %>
|
|
31
|
+
</div>
|
|
32
|
+
</div>
|
|
33
|
+
|
|
34
|
+
<div class="progress-bar">
|
|
35
|
+
<% pct = (@worker[:current_workers].to_f / @worker[:max_workers] * 100).round %>
|
|
36
|
+
<div class="fill" style="width: <%= pct %>%; background: <%= pct > 80 ? 'var(--warning)' : 'var(--success)' %>;"></div>
|
|
37
|
+
</div>
|
|
38
|
+
|
|
39
|
+
<div class="grid grid-2 mt-2">
|
|
40
|
+
<div>
|
|
41
|
+
<div class="text-muted">Process Type</div>
|
|
42
|
+
<code><%= @worker[:process_type] %></code>
|
|
43
|
+
</div>
|
|
44
|
+
<div>
|
|
45
|
+
<div class="text-muted">Strategy</div>
|
|
46
|
+
<code><%= @worker[:scaling_strategy] %></code>
|
|
47
|
+
</div>
|
|
48
|
+
<div>
|
|
49
|
+
<div class="text-muted">Queues</div>
|
|
50
|
+
<code><%= Array(@worker[:queues]).join(', ') %></code>
|
|
51
|
+
</div>
|
|
52
|
+
</div>
|
|
53
|
+
</div>
|
|
54
|
+
|
|
55
|
+
<!-- Metrics -->
|
|
56
|
+
<div class="card">
|
|
57
|
+
<h3>Current Metrics</h3>
|
|
58
|
+
|
|
59
|
+
<div class="grid grid-2">
|
|
60
|
+
<div class="mb-2">
|
|
61
|
+
<div class="text-muted">Queue Depth</div>
|
|
62
|
+
<strong class="<%= @worker[:metrics][:queue_depth] > @worker[:thresholds][:scale_up_queue_depth] ? 'text-danger' : '' %>" style="font-size: 1.5rem;">
|
|
63
|
+
<%= @worker[:metrics][:queue_depth] %>
|
|
64
|
+
</strong>
|
|
65
|
+
<div class="text-muted" style="font-size: 0.8rem;">
|
|
66
|
+
↑ threshold: <%= @worker[:thresholds][:scale_up_queue_depth] %> | ↓ threshold: <%= @worker[:thresholds][:scale_down_queue_depth] %>
|
|
67
|
+
</div>
|
|
68
|
+
</div>
|
|
69
|
+
|
|
70
|
+
<div class="mb-2">
|
|
71
|
+
<div class="text-muted">Oldest Job Age</div>
|
|
72
|
+
<strong class="<%= @worker[:metrics][:latency_seconds] > @worker[:thresholds][:scale_up_latency] ? 'text-danger' : '' %>" style="font-size: 1.5rem;">
|
|
73
|
+
<%= @worker[:metrics][:latency_seconds].round %>s
|
|
74
|
+
</strong>
|
|
75
|
+
<div class="text-muted" style="font-size: 0.8rem;">
|
|
76
|
+
↑ threshold: <%= @worker[:thresholds][:scale_up_latency] %>s | ↓ threshold: <%= @worker[:thresholds][:scale_down_latency] %>s
|
|
77
|
+
</div>
|
|
78
|
+
</div>
|
|
79
|
+
|
|
80
|
+
<div>
|
|
81
|
+
<div class="text-muted">Jobs/Minute</div>
|
|
82
|
+
<strong style="font-size: 1.5rem;"><%= @worker[:metrics][:jobs_per_minute] %></strong>
|
|
83
|
+
</div>
|
|
84
|
+
|
|
85
|
+
<div>
|
|
86
|
+
<div class="text-muted">Claimed Jobs</div>
|
|
87
|
+
<strong style="font-size: 1.5rem;"><%= @worker[:metrics][:claimed_jobs] %></strong>
|
|
88
|
+
</div>
|
|
89
|
+
|
|
90
|
+
<div>
|
|
91
|
+
<div class="text-muted">Failed Jobs</div>
|
|
92
|
+
<strong class="<%= @worker[:metrics][:failed_jobs] > 0 ? 'text-danger' : '' %>" style="font-size: 1.5rem;">
|
|
93
|
+
<%= @worker[:metrics][:failed_jobs] %>
|
|
94
|
+
</strong>
|
|
95
|
+
</div>
|
|
96
|
+
|
|
97
|
+
<div>
|
|
98
|
+
<div class="text-muted">Active Workers</div>
|
|
99
|
+
<strong style="font-size: 1.5rem;"><%= @worker[:metrics][:active_workers] %></strong>
|
|
100
|
+
</div>
|
|
101
|
+
</div>
|
|
102
|
+
</div>
|
|
103
|
+
</div>
|
|
104
|
+
|
|
105
|
+
<!-- Cooldowns -->
|
|
106
|
+
<div class="card mb-3">
|
|
107
|
+
<h3>Cooldown Status</h3>
|
|
108
|
+
|
|
109
|
+
<div class="grid grid-2">
|
|
110
|
+
<div>
|
|
111
|
+
<div class="text-muted mb-1">Scale Up Cooldown</div>
|
|
112
|
+
<% if @worker[:cooldowns][:scale_up_remaining] > 0 %>
|
|
113
|
+
<span class="badge badge-info">
|
|
114
|
+
<%= @worker[:cooldowns][:scale_up_remaining] %>s remaining
|
|
115
|
+
</span>
|
|
116
|
+
<% else %>
|
|
117
|
+
<span class="text-success">Ready</span>
|
|
118
|
+
<% end %>
|
|
119
|
+
<% if @worker[:cooldowns][:last_scale_up] %>
|
|
120
|
+
<div class="text-muted" style="font-size: 0.8rem;">
|
|
121
|
+
Last: <%= @worker[:cooldowns][:last_scale_up].strftime('%Y-%m-%d %H:%M:%S') %>
|
|
122
|
+
</div>
|
|
123
|
+
<% end %>
|
|
124
|
+
</div>
|
|
125
|
+
|
|
126
|
+
<div>
|
|
127
|
+
<div class="text-muted mb-1">Scale Down Cooldown</div>
|
|
128
|
+
<% if @worker[:cooldowns][:scale_down_remaining] > 0 %>
|
|
129
|
+
<span class="badge badge-warning">
|
|
130
|
+
<%= @worker[:cooldowns][:scale_down_remaining] %>s remaining
|
|
131
|
+
</span>
|
|
132
|
+
<% else %>
|
|
133
|
+
<span class="text-success">Ready</span>
|
|
134
|
+
<% end %>
|
|
135
|
+
<% if @worker[:cooldowns][:last_scale_down] %>
|
|
136
|
+
<div class="text-muted" style="font-size: 0.8rem;">
|
|
137
|
+
Last: <%= @worker[:cooldowns][:last_scale_down].strftime('%Y-%m-%d %H:%M:%S') %>
|
|
138
|
+
</div>
|
|
139
|
+
<% end %>
|
|
140
|
+
</div>
|
|
141
|
+
</div>
|
|
142
|
+
</div>
|
|
143
|
+
|
|
144
|
+
<!-- Recent Events -->
|
|
145
|
+
<div class="card">
|
|
146
|
+
<div class="d-flex justify-between align-center mb-2">
|
|
147
|
+
<h3>Recent Events</h3>
|
|
148
|
+
<%= link_to 'View All', events_path(worker: @worker[:name]), class: 'btn btn-secondary btn-sm' %>
|
|
149
|
+
</div>
|
|
150
|
+
|
|
151
|
+
<% if !events_available? %>
|
|
152
|
+
<p class="text-muted">
|
|
153
|
+
Events table not found. Run:
|
|
154
|
+
<code>rails generate solid_queue_heroku_autoscaler:dashboard</code>
|
|
155
|
+
</p>
|
|
156
|
+
<% elsif @events.empty? %>
|
|
157
|
+
<p class="text-muted">No scale events recorded for this worker.</p>
|
|
158
|
+
<% else %>
|
|
159
|
+
<table>
|
|
160
|
+
<thead>
|
|
161
|
+
<tr>
|
|
162
|
+
<th>Time</th>
|
|
163
|
+
<th>Action</th>
|
|
164
|
+
<th>Workers</th>
|
|
165
|
+
<th>Queue Depth</th>
|
|
166
|
+
<th>Latency</th>
|
|
167
|
+
<th>Reason</th>
|
|
168
|
+
</tr>
|
|
169
|
+
</thead>
|
|
170
|
+
<tbody>
|
|
171
|
+
<% @events.each do |event| %>
|
|
172
|
+
<tr>
|
|
173
|
+
<td>
|
|
174
|
+
<div><%= event.created_at.strftime('%Y-%m-%d') %></div>
|
|
175
|
+
<div class="time-ago"><%= event.created_at.strftime('%H:%M:%S') %></div>
|
|
176
|
+
</td>
|
|
177
|
+
<td>
|
|
178
|
+
<% case event.action %>
|
|
179
|
+
<% when 'scale_up' %>
|
|
180
|
+
<span class="badge badge-success">↑ Scale Up</span>
|
|
181
|
+
<% when 'scale_down' %>
|
|
182
|
+
<span class="badge badge-warning">↓ Scale Down</span>
|
|
183
|
+
<% when 'no_change' %>
|
|
184
|
+
<span class="badge badge-neutral">— No Change</span>
|
|
185
|
+
<% when 'skipped' %>
|
|
186
|
+
<span class="badge badge-info">⏭ Skipped</span>
|
|
187
|
+
<% when 'error' %>
|
|
188
|
+
<span class="badge badge-danger">✕ Error</span>
|
|
189
|
+
<% end %>
|
|
190
|
+
<% if event.dry_run %>
|
|
191
|
+
<span class="badge badge-neutral">DRY</span>
|
|
192
|
+
<% end %>
|
|
193
|
+
</td>
|
|
194
|
+
<td>
|
|
195
|
+
<% if event.scaled? %>
|
|
196
|
+
<strong><%= event.from_workers %></strong> → <strong><%= event.to_workers %></strong>
|
|
197
|
+
<% else %>
|
|
198
|
+
<%= event.from_workers %>
|
|
199
|
+
<% end %>
|
|
200
|
+
</td>
|
|
201
|
+
<td><%= event.queue_depth %></td>
|
|
202
|
+
<td><%= event.latency_seconds.round %>s</td>
|
|
203
|
+
<td class="text-muted"><%= event.reason %></td>
|
|
204
|
+
</tr>
|
|
205
|
+
<% end %>
|
|
206
|
+
</tbody>
|
|
207
|
+
</table>
|
|
208
|
+
<% end %>
|
|
209
|
+
</div>
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'dashboard/engine' if defined?(Rails::Engine)
|
|
4
|
+
|
|
5
|
+
module SolidQueueHerokuAutoscaler
|
|
6
|
+
# Dashboard module provides a web UI for monitoring the autoscaler.
|
|
7
|
+
# Integrates with Mission Control Solid Queue when available.
|
|
8
|
+
module Dashboard
|
|
9
|
+
class << self
|
|
10
|
+
# Returns current autoscaler status for all workers
|
|
11
|
+
# @return [Hash] Status information for all workers
|
|
12
|
+
def status
|
|
13
|
+
workers = SolidQueueHerokuAutoscaler.registered_workers
|
|
14
|
+
workers = [:default] if workers.empty?
|
|
15
|
+
|
|
16
|
+
workers.each_with_object({}) do |name, status|
|
|
17
|
+
status[name] = worker_status(name)
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Returns status for a specific worker
|
|
22
|
+
# @param name [Symbol] Worker name
|
|
23
|
+
# @return [Hash] Status information
|
|
24
|
+
def worker_status(name)
|
|
25
|
+
config = SolidQueueHerokuAutoscaler.config(name)
|
|
26
|
+
metrics = safe_metrics(name)
|
|
27
|
+
tracker = CooldownTracker.new(config: config, key: name.to_s)
|
|
28
|
+
|
|
29
|
+
{
|
|
30
|
+
name: name,
|
|
31
|
+
enabled: config.enabled?,
|
|
32
|
+
dry_run: config.dry_run?,
|
|
33
|
+
current_workers: safe_current_workers(name),
|
|
34
|
+
min_workers: config.min_workers,
|
|
35
|
+
max_workers: config.max_workers,
|
|
36
|
+
queues: config.queues || ['all'],
|
|
37
|
+
process_type: config.process_type,
|
|
38
|
+
scaling_strategy: config.scaling_strategy,
|
|
39
|
+
metrics: {
|
|
40
|
+
queue_depth: metrics&.queue_depth || 0,
|
|
41
|
+
latency_seconds: metrics&.oldest_job_age_seconds || 0,
|
|
42
|
+
jobs_per_minute: metrics&.jobs_per_minute || 0,
|
|
43
|
+
claimed_jobs: metrics&.claimed_jobs || 0,
|
|
44
|
+
failed_jobs: metrics&.failed_jobs || 0,
|
|
45
|
+
active_workers: metrics&.active_workers || 0
|
|
46
|
+
},
|
|
47
|
+
cooldowns: {
|
|
48
|
+
scale_up_remaining: tracker.scale_up_cooldown_remaining.round,
|
|
49
|
+
scale_down_remaining: tracker.scale_down_cooldown_remaining.round,
|
|
50
|
+
last_scale_up: tracker.last_scale_up_at,
|
|
51
|
+
last_scale_down: tracker.last_scale_down_at
|
|
52
|
+
},
|
|
53
|
+
thresholds: {
|
|
54
|
+
scale_up_queue_depth: config.scale_up_queue_depth,
|
|
55
|
+
scale_up_latency: config.scale_up_latency_seconds,
|
|
56
|
+
scale_down_queue_depth: config.scale_down_queue_depth,
|
|
57
|
+
scale_down_latency: config.scale_down_latency_seconds
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Returns recent scale events
|
|
63
|
+
# @param limit [Integer] Maximum events to return
|
|
64
|
+
# @param worker_name [String, nil] Filter by worker
|
|
65
|
+
# @return [Array<ScaleEvent>] Recent events
|
|
66
|
+
def recent_events(limit: 50, worker_name: nil)
|
|
67
|
+
ScaleEvent.recent(limit: limit, worker_name: worker_name)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Returns event statistics
|
|
71
|
+
# @param since [Time] Start time
|
|
72
|
+
# @param worker_name [String, nil] Filter by worker
|
|
73
|
+
# @return [Hash] Statistics
|
|
74
|
+
def event_stats(since: 24.hours.ago, worker_name: nil)
|
|
75
|
+
ScaleEvent.stats(since: since, worker_name: worker_name)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Checks if the events table is available
|
|
79
|
+
# @return [Boolean] True if events can be recorded
|
|
80
|
+
def events_table_available?
|
|
81
|
+
ScaleEvent.table_exists?
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
private
|
|
85
|
+
|
|
86
|
+
def safe_metrics(name)
|
|
87
|
+
SolidQueueHerokuAutoscaler.metrics(name)
|
|
88
|
+
rescue StandardError
|
|
89
|
+
nil
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def safe_current_workers(name)
|
|
93
|
+
SolidQueueHerokuAutoscaler.current_workers(name)
|
|
94
|
+
rescue StandardError
|
|
95
|
+
0
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|