rails_error_dashboard 0.3.0 → 0.3.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d6b9a0833340184697ca86044a78e7a91ef89a3e328606c246df4dc8ced28eb7
4
- data.tar.gz: b2f94352f6a035f842c36e277374b1b60bd2bc2cd3f48c5480e640073658395f
3
+ metadata.gz: f95521c0c769dd28fdd0934540282c64f32a8d23a8c6be9a63805326384b1c01
4
+ data.tar.gz: 3c66324e1e40e8fa0b474a6566babe67eb831927269f37a0108e77db6c928749
5
5
  SHA512:
6
- metadata.gz: f296da936589382a6fd7d727d90cf82f115d91fbb30ff63c0fe56df1b6197f09ca46accc3d39d86718b5abaaee32aa9a11fd18089750e16d9f3bb13243fcf6ef
7
- data.tar.gz: d2cbd1a52e7c85153b28fa7537650776b9aff1ae6588626c0ab5e76a3f2c45bbf7d30774a99530c3faa3c708edb1ae42d9dfc8dbc6709acff2d285bb7035ac47
6
+ metadata.gz: 9ca327e64bea48cc347e96969db0d2da76c4ffe347ba5aad41c8576b6f18b5696fdd64d6df8f910c2b8df4a64dafc2f75f925f9aa9610b20346535d5e05ffbfc
7
+ data.tar.gz: 5188358be68c4831caff5808db350058f334bc9d9e3310acd4bccab8ebef667785035b7481fd2f46ed5bc12e793f4b481f44ae15765babf410ea9292dedfb3d4
data/README.md CHANGED
@@ -54,10 +54,18 @@ Experience the full dashboard with 480+ realistic Rails errors, LOTR-themed demo
54
54
 
55
55
  ![Cache Health](docs/images/cache-health.png)
56
56
 
57
+ **Job Health** — Background job queue stats across errors, sorted by failed count.
58
+
59
+ ![Job Health](docs/images/job-health.png)
60
+
61
+ **Database Health** — Connection pool utilization, PostgreSQL table stats, and per-error stress scores.
62
+
63
+ ![Database Health](docs/images/database-health.png)
64
+
57
65
  ---
58
66
 
59
67
  ### ⚠️ BETA SOFTWARE
60
- This Rails Engine is in beta and under active development. While functional and tested (2,100+ tests passing, including browser-based system tests), the API may change before v1.0.0. Use in production at your own discretion.
68
+ This Rails Engine is in beta and under active development. While functional and tested (2,226+ tests passing, including browser-based system tests), the API may change before v1.0.0. Use in production at your own discretion.
61
69
 
62
70
  **Supports**: Rails 7.0 - 8.1 | Ruby 3.2 - 4.0
63
71
 
@@ -228,6 +236,27 @@ config.enable_system_health = true
228
236
 
229
237
  **📖 [Complete documentation →](docs/FEATURES.md#system-health-snapshot-new)**
230
238
 
239
+ #### 🏭 Job Health Page
240
+
241
+ **See background job queue health alongside your errors** — auto-detects Sidekiq, SolidQueue, or GoodJob stats captured at error time.
242
+
243
+ - **Per-error table** — Adapter badge, failed count (color-coded), queued/enqueued, other stats
244
+ - **Summary cards** — Errors with job data, total failed, adapters detected
245
+ - **Sorted worst-first** — Highest failed count first
246
+
247
+ **📖 [Complete documentation →](docs/FEATURES.md#job-health-page)**
248
+
249
+ #### 🗄️ Database Health Page
250
+
251
+ **PgHero-style database health built into the dashboard** — live PostgreSQL stats + historical connection pool data from error snapshots.
252
+
253
+ - **Live stats** (PostgreSQL) — Table sizes, unused indexes, dead tuples, vacuum timestamps, connection activity
254
+ - **Historical pool data** (all adapters) — Per-error connection pool utilization, sorted by stress score
255
+ - **Color-coded** — Utilization >=80% danger, >=60% warning; dead/waiting badges
256
+ - **Non-PG friendly** — SQLite/MySQL still see connection pool stats and historical data
257
+
258
+ **📖 [Complete documentation →](docs/FEATURES.md#database-health-page)**
259
+
231
260
  #### 🆕 v0.2 Quick Wins (NEW!)
232
261
 
233
262
  **11 features that make error tracking smarter, safer, and more actionable:**
@@ -297,6 +297,57 @@ module RailsErrorDashboard
297
297
  @pagy, @entries = pagy(:offset, all_entries, limit: params[:per_page] || 25)
298
298
  end
299
299
 
300
+ def job_health_summary
301
+ unless RailsErrorDashboard.configuration.enable_system_health
302
+ flash[:alert] = "System health is not enabled. Enable it in config/initializers/rails_error_dashboard.rb"
303
+ redirect_to errors_path
304
+ return
305
+ end
306
+
307
+ days = (params[:days] || 30).to_i
308
+ @days = days
309
+ result = Queries::JobHealthSummary.call(days, application_id: @current_application_id)
310
+ all_entries = result[:entries]
311
+
312
+ # Summary stats (computed before pagination)
313
+ @errors_with_jobs = all_entries.size
314
+ @total_failed = all_entries.sum { |e| e[:failed] || e[:errored] || 0 }
315
+ @adapters_detected = all_entries.map { |e| e[:adapter] }.uniq
316
+
317
+ @pagy, @entries = pagy(:offset, all_entries, limit: params[:per_page] || 25)
318
+ end
319
+
320
+ def database_health_summary
321
+ unless RailsErrorDashboard.configuration.enable_system_health
322
+ flash[:alert] = "System health is not enabled. Enable it in config/initializers/rails_error_dashboard.rb"
323
+ redirect_to errors_path
324
+ return
325
+ end
326
+
327
+ days = (params[:days] || 30).to_i
328
+ @days = days
329
+
330
+ # Live database health (display-time only)
331
+ @live_health = Services::DatabaseHealthInspector.call
332
+
333
+ # Separate host vs gem tables from live data
334
+ all_tables = @live_health[:tables] || []
335
+ @host_tables = all_tables.reject { |t| t[:gem_table] }
336
+ @gem_tables = all_tables.select { |t| t[:gem_table] }
337
+
338
+ # Historical connection pool stats
339
+ result = Queries::DatabaseHealthSummary.call(days, application_id: @current_application_id)
340
+ all_entries = result[:entries]
341
+
342
+ # Summary stats (computed before pagination)
343
+ @errors_with_pool = all_entries.size
344
+ @max_utilization = all_entries.map { |e| e[:utilization] }.max || 0
345
+ @total_dead = all_entries.sum { |e| e[:dead] }
346
+ @total_waiting = all_entries.sum { |e| e[:waiting] }
347
+
348
+ @pagy, @entries = pagy(:offset, all_entries, limit: params[:per_page] || 25)
349
+ end
350
+
300
351
  def settings
301
352
  @config = RailsErrorDashboard.configuration
302
353
  end
@@ -1657,6 +1657,18 @@ body.dark-mode .sidebar-section-body { background: var(--ctp-mantle); }
1657
1657
  <% end %>
1658
1658
  </li>
1659
1659
  <% end %>
1660
+ <% if RailsErrorDashboard.configuration.enable_system_health %>
1661
+ <li class="nav-item">
1662
+ <%= link_to job_health_summary_errors_path(nav_params), class: "nav-link #{request.path == job_health_summary_errors_path ? 'active' : ''}" do %>
1663
+ <i class="bi bi-cpu"></i> Job Health
1664
+ <% end %>
1665
+ </li>
1666
+ <li class="nav-item">
1667
+ <%= link_to database_health_summary_errors_path(nav_params), class: "nav-link #{request.path == database_health_summary_errors_path ? 'active' : ''}" do %>
1668
+ <i class="bi bi-database"></i> DB Health
1669
+ <% end %>
1670
+ </li>
1671
+ <% end %>
1660
1672
  </ul>
1661
1673
 
1662
1674
  <h6 class="mt-4">QUICK FILTERS</h6>
@@ -78,16 +78,27 @@
78
78
  <td><code><%= error.ip_address || 'N/A' %></code></td>
79
79
  </tr>
80
80
  <% curl_cmd = RailsErrorDashboard::Services::CurlGenerator.call(error) %>
81
- <% if curl_cmd.present? %>
81
+ <% rspec_cmd = RailsErrorDashboard::Services::RspecGenerator.call(error) %>
82
+ <% if curl_cmd.present? || rspec_cmd.present? %>
82
83
  <tr>
83
84
  <th>Replay Request:</th>
84
85
  <td>
85
- <button class="btn btn-sm btn-outline-primary"
86
- onclick="copyToClipboard(this.dataset.curl, this)"
87
- data-curl="<%= h curl_cmd %>"
88
- title="Copy curl command to clipboard">
89
- <i class="bi bi-terminal"></i> Copy as curl
90
- </button>
86
+ <% if curl_cmd.present? %>
87
+ <button class="btn btn-sm btn-outline-primary me-2"
88
+ onclick="copyToClipboard(this.dataset.curl, this)"
89
+ data-curl="<%= h curl_cmd %>"
90
+ title="Copy curl command to clipboard">
91
+ <i class="bi bi-terminal"></i> Copy as curl
92
+ </button>
93
+ <% end %>
94
+ <% if rspec_cmd.present? %>
95
+ <button class="btn btn-sm btn-outline-success"
96
+ onclick="copyToClipboard(this.dataset.curl, this)"
97
+ data-curl="<%= h rspec_cmd %>"
98
+ title="Copy RSpec request spec to clipboard">
99
+ <i class="bi bi-code-slash"></i> Copy as RSpec
100
+ </button>
101
+ <% end %>
91
102
  </td>
92
103
  </tr>
93
104
  <% end %>
@@ -0,0 +1,450 @@
1
+ <% content_for :page_title, "DB Health" %>
2
+
3
+ <div class="container-fluid py-4">
4
+ <div class="d-flex justify-content-between align-items-center mb-4">
5
+ <h1 class="h3 mb-0">
6
+ <i class="bi bi-database me-2"></i>
7
+ Database Health
8
+ </h1>
9
+
10
+ <div class="btn-group" role="group">
11
+ <%= link_to database_health_summary_errors_path(days: 7), class: "btn btn-sm #{@days == 7 ? 'btn-primary' : 'btn-outline-primary'}" do %>
12
+ 7 Days
13
+ <% end %>
14
+ <%= link_to database_health_summary_errors_path(days: 30), class: "btn btn-sm #{@days == 30 ? 'btn-primary' : 'btn-outline-primary'}" do %>
15
+ 30 Days
16
+ <% end %>
17
+ <%= link_to database_health_summary_errors_path(days: 90), class: "btn btn-sm #{@days == 90 ? 'btn-primary' : 'btn-outline-primary'}" do %>
18
+ 90 Days
19
+ <% end %>
20
+ </div>
21
+ </div>
22
+
23
+ <%# ===== SECTION A: Live Database Health ===== %>
24
+
25
+ <h4 class="mb-3"><i class="bi bi-activity text-primary me-2"></i>Live Database Health</h4>
26
+
27
+ <% unless @live_health[:postgresql] %>
28
+ <div class="alert alert-info mb-3">
29
+ <i class="bi bi-info-circle me-2"></i>
30
+ <strong>Non-PostgreSQL adapter detected (<%= @live_health[:adapter] || "unknown" %>).</strong>
31
+ Live table, index, and activity stats require PostgreSQL. Connection pool and historical data are still available below.
32
+ </div>
33
+ <% end %>
34
+
35
+ <%# Connection Pool (always works — all adapters) %>
36
+ <% if @live_health[:connection_pool] %>
37
+ <% pool = @live_health[:connection_pool] %>
38
+ <div class="row mb-4">
39
+ <div class="col-md-2">
40
+ <div class="card text-center">
41
+ <div class="card-body py-2">
42
+ <div class="h4 mb-0 text-primary"><%= pool[:size] %></div>
43
+ <small class="text-muted">Pool Size</small>
44
+ </div>
45
+ </div>
46
+ </div>
47
+ <div class="col-md-2">
48
+ <div class="card text-center">
49
+ <div class="card-body py-2">
50
+ <% busy_color = pool[:size] > 0 && (pool[:busy].to_f / pool[:size]) >= 0.8 ? "danger" : "info" %>
51
+ <div class="h4 mb-0 text-<%= busy_color %>"><%= pool[:busy] %></div>
52
+ <small class="text-muted">Busy</small>
53
+ </div>
54
+ </div>
55
+ </div>
56
+ <div class="col-md-2">
57
+ <div class="card text-center">
58
+ <div class="card-body py-2">
59
+ <div class="h4 mb-0 text-success"><%= pool[:idle] %></div>
60
+ <small class="text-muted">Idle</small>
61
+ </div>
62
+ </div>
63
+ </div>
64
+ <div class="col-md-2">
65
+ <div class="card text-center">
66
+ <div class="card-body py-2">
67
+ <% dead_color = pool[:dead] > 0 ? "danger" : "success" %>
68
+ <div class="h4 mb-0 text-<%= dead_color %>"><%= pool[:dead] %></div>
69
+ <small class="text-muted">Dead</small>
70
+ </div>
71
+ </div>
72
+ </div>
73
+ <div class="col-md-2">
74
+ <div class="card text-center">
75
+ <div class="card-body py-2">
76
+ <% waiting_color = pool[:waiting] > 0 ? "warning" : "success" %>
77
+ <div class="h4 mb-0 text-<%= waiting_color %>"><%= pool[:waiting] %></div>
78
+ <small class="text-muted">Waiting</small>
79
+ </div>
80
+ </div>
81
+ </div>
82
+ </div>
83
+ <% end %>
84
+
85
+ <% if @live_health[:postgresql] %>
86
+ <%# Summary cards for PostgreSQL %>
87
+ <% total_db_size = (@host_tables + @gem_tables).sum { |t| t[:total_bytes] } %>
88
+ <div class="row mb-4">
89
+ <div class="col-md-3">
90
+ <div class="card text-center">
91
+ <div class="card-body py-2">
92
+ <div class="h4 mb-0 text-primary"><%= number_to_human_size(total_db_size) %></div>
93
+ <small class="text-muted">Total DB Size</small>
94
+ </div>
95
+ </div>
96
+ </div>
97
+ <div class="col-md-3">
98
+ <div class="card text-center">
99
+ <div class="card-body py-2">
100
+ <div class="h4 mb-0 text-info"><%= @host_tables.size + @gem_tables.size %></div>
101
+ <small class="text-muted">Tables</small>
102
+ </div>
103
+ </div>
104
+ </div>
105
+ <div class="col-md-3">
106
+ <div class="card text-center">
107
+ <div class="card-body py-2">
108
+ <% unused_color = @live_health[:unused_indexes]&.any? ? "warning" : "success" %>
109
+ <div class="h4 mb-0 text-<%= unused_color %>"><%= @live_health[:unused_indexes]&.size || 0 %></div>
110
+ <small class="text-muted">Unused Indexes</small>
111
+ </div>
112
+ </div>
113
+ </div>
114
+ <div class="col-md-3">
115
+ <div class="card text-center">
116
+ <div class="card-body py-2">
117
+ <div class="h4 mb-0 text-secondary"><%= @live_health[:activity] ? @live_health[:activity][:total] : "N/A" %></div>
118
+ <small class="text-muted">Active Connections</small>
119
+ </div>
120
+ </div>
121
+ </div>
122
+ </div>
123
+
124
+ <%# Host App Tables %>
125
+ <% if @host_tables.any? %>
126
+ <div class="card mb-4">
127
+ <div class="card-header bg-white">
128
+ <h5 class="mb-0">
129
+ <i class="bi bi-table text-primary me-2"></i>
130
+ Host App Tables
131
+ <span class="badge bg-primary"><%= @host_tables.size %></span>
132
+ </h5>
133
+ </div>
134
+ <div class="card-body p-0">
135
+ <div class="table-responsive">
136
+ <table class="table table-hover mb-0">
137
+ <thead class="table-light">
138
+ <tr>
139
+ <th>Table</th>
140
+ <th width="100">Est. Rows</th>
141
+ <th width="100">Size</th>
142
+ <th width="80">Seq Scans</th>
143
+ <th width="80">Idx Scans</th>
144
+ <th width="100">Dead Tuples</th>
145
+ <th width="140">Last Vacuum</th>
146
+ </tr>
147
+ </thead>
148
+ <tbody>
149
+ <% @host_tables.each do |table| %>
150
+ <tr>
151
+ <td><code><%= table[:name] %></code></td>
152
+ <td><%= number_with_delimiter(table[:estimated_rows]) %></td>
153
+ <td><%= number_to_human_size(table[:total_bytes]) %></td>
154
+ <td><%= number_with_delimiter(table[:seq_scan]) %></td>
155
+ <td><%= number_with_delimiter(table[:idx_scan]) %></td>
156
+ <td>
157
+ <% if table[:dead_tuples] > 1000 %>
158
+ <span class="badge bg-warning text-dark"><%= number_with_delimiter(table[:dead_tuples]) %></span>
159
+ <% else %>
160
+ <%= number_with_delimiter(table[:dead_tuples]) %>
161
+ <% end %>
162
+ </td>
163
+ <td><%= table[:last_autovacuum] ? local_time_ago(Time.parse(table[:last_autovacuum])) : "Never" %></td>
164
+ </tr>
165
+ <% end %>
166
+ </tbody>
167
+ </table>
168
+ </div>
169
+ </div>
170
+ </div>
171
+ <% end %>
172
+
173
+ <%# Gem Tables (collapsible) %>
174
+ <% if @gem_tables.any? %>
175
+ <div class="card mb-4">
176
+ <div class="card-header bg-white">
177
+ <a class="text-decoration-none" data-bs-toggle="collapse" href="#gemTablesCollapse" role="button" aria-expanded="false" aria-controls="gemTablesCollapse">
178
+ <h5 class="mb-0">
179
+ <i class="bi bi-table text-secondary me-2"></i>
180
+ Error Dashboard Tables
181
+ <span class="badge bg-secondary"><%= @gem_tables.size %></span>
182
+ <i class="bi bi-chevron-down ms-2" style="font-size: 0.8em;"></i>
183
+ </h5>
184
+ </a>
185
+ </div>
186
+ <div class="collapse" id="gemTablesCollapse">
187
+ <div class="card-body p-0">
188
+ <div class="table-responsive">
189
+ <table class="table table-hover mb-0">
190
+ <thead class="table-light">
191
+ <tr>
192
+ <th>Table</th>
193
+ <th width="100">Est. Rows</th>
194
+ <th width="100">Size</th>
195
+ <th width="80">Seq Scans</th>
196
+ <th width="80">Idx Scans</th>
197
+ <th width="100">Dead Tuples</th>
198
+ <th width="140">Last Vacuum</th>
199
+ </tr>
200
+ </thead>
201
+ <tbody>
202
+ <% @gem_tables.each do |table| %>
203
+ <tr>
204
+ <td><code><%= table[:name] %></code></td>
205
+ <td><%= number_with_delimiter(table[:estimated_rows]) %></td>
206
+ <td><%= number_to_human_size(table[:total_bytes]) %></td>
207
+ <td><%= number_with_delimiter(table[:seq_scan]) %></td>
208
+ <td><%= number_with_delimiter(table[:idx_scan]) %></td>
209
+ <td>
210
+ <% if table[:dead_tuples] > 1000 %>
211
+ <span class="badge bg-warning text-dark"><%= number_with_delimiter(table[:dead_tuples]) %></span>
212
+ <% else %>
213
+ <%= number_with_delimiter(table[:dead_tuples]) %>
214
+ <% end %>
215
+ </td>
216
+ <td><%= table[:last_autovacuum] ? local_time_ago(Time.parse(table[:last_autovacuum])) : "Never" %></td>
217
+ </tr>
218
+ <% end %>
219
+ </tbody>
220
+ </table>
221
+ </div>
222
+ </div>
223
+ </div>
224
+ </div>
225
+ <% end %>
226
+
227
+ <%# Unused Indexes %>
228
+ <% if @live_health[:unused_indexes]&.any? %>
229
+ <div class="card mb-4">
230
+ <div class="card-header bg-white">
231
+ <h5 class="mb-0">
232
+ <i class="bi bi-exclamation-triangle text-warning me-2"></i>
233
+ Unused Indexes
234
+ <span class="badge bg-warning text-dark"><%= @live_health[:unused_indexes].size %></span>
235
+ </h5>
236
+ </div>
237
+ <div class="card-body p-0">
238
+ <div class="table-responsive">
239
+ <table class="table table-hover mb-0">
240
+ <thead class="table-light">
241
+ <tr>
242
+ <th>Index</th>
243
+ <th>Table</th>
244
+ <th width="100">Size</th>
245
+ </tr>
246
+ </thead>
247
+ <tbody>
248
+ <% @live_health[:unused_indexes].each do |idx| %>
249
+ <tr>
250
+ <td><code><%= idx[:name] %></code></td>
251
+ <td><code><%= idx[:table_name] %></code></td>
252
+ <td><span class="badge bg-warning text-dark"><%= number_to_human_size(idx[:size_bytes]) %></span></td>
253
+ </tr>
254
+ <% end %>
255
+ </tbody>
256
+ </table>
257
+ </div>
258
+ </div>
259
+ </div>
260
+ <% end %>
261
+
262
+ <%# Activity by State %>
263
+ <% if @live_health[:activity] %>
264
+ <div class="card mb-4">
265
+ <div class="card-header bg-white">
266
+ <h5 class="mb-0">
267
+ <i class="bi bi-people text-info me-2"></i>
268
+ Connection Activity
269
+ <span class="badge bg-info"><%= @live_health[:activity][:total] %> total</span>
270
+ <% if @live_health[:activity][:total_waiting] > 0 %>
271
+ <span class="badge bg-warning text-dark"><%= @live_health[:activity][:total_waiting] %> waiting</span>
272
+ <% end %>
273
+ </h5>
274
+ </div>
275
+ <div class="card-body p-0">
276
+ <div class="table-responsive">
277
+ <table class="table table-hover mb-0">
278
+ <thead class="table-light">
279
+ <tr>
280
+ <th>State</th>
281
+ <th width="100">Count</th>
282
+ <th width="100">Waiting</th>
283
+ </tr>
284
+ </thead>
285
+ <tbody>
286
+ <% @live_health[:activity][:by_state].each do |row| %>
287
+ <tr>
288
+ <td><span class="badge bg-secondary"><%= row[:state] %></span></td>
289
+ <td><%= row[:count] %></td>
290
+ <td>
291
+ <% if row[:waiting] > 0 %>
292
+ <span class="badge bg-warning text-dark"><%= row[:waiting] %></span>
293
+ <% else %>
294
+ 0
295
+ <% end %>
296
+ </td>
297
+ </tr>
298
+ <% end %>
299
+ </tbody>
300
+ </table>
301
+ </div>
302
+ </div>
303
+ </div>
304
+ <% end %>
305
+ <% end %>
306
+
307
+ <%# ===== SECTION B: Historical Connection Pool at Error Time ===== %>
308
+
309
+ <hr class="my-4">
310
+ <h4 class="mb-3"><i class="bi bi-clock-history text-secondary me-2"></i>Historical Connection Pool at Error Time</h4>
311
+
312
+ <% if @errors_with_pool == 0 %>
313
+ <div class="text-center py-5">
314
+ <i class="bi bi-check-circle display-1 text-success mb-3"></i>
315
+ <h4 class="text-muted">No Connection Pool Data Found</h4>
316
+ <p class="text-muted">
317
+ No connection pool stats were detected in system health snapshots over the last <%= @days %> days.
318
+ </p>
319
+ <div class="card mx-auto" style="max-width: 500px;">
320
+ <div class="card-body text-start">
321
+ <h6>How connection pool tracking works:</h6>
322
+ <ul class="mb-0">
323
+ <li>System health must be enabled (<code>enable_system_health = true</code>)</li>
324
+ <li>Connection pool stats are captured automatically at error time</li>
325
+ <li>Stats include pool size, busy, idle, dead, and waiting connections</li>
326
+ <li>This section shows pool health per-error, sorted by stress score</li>
327
+ </ul>
328
+ </div>
329
+ </div>
330
+ </div>
331
+ <% else %>
332
+ <div class="row mb-4">
333
+ <div class="col-md-3">
334
+ <div class="card text-center">
335
+ <div class="card-body">
336
+ <div class="display-6 text-info"><%= @errors_with_pool %></div>
337
+ <small class="text-muted">Errors with Pool Data</small>
338
+ </div>
339
+ </div>
340
+ </div>
341
+ <div class="col-md-3">
342
+ <div class="card text-center">
343
+ <div class="card-body">
344
+ <% util_color = @max_utilization >= 80 ? "danger" : (@max_utilization >= 60 ? "warning" : "success") %>
345
+ <div class="display-6 text-<%= util_color %>"><%= @max_utilization %>%</div>
346
+ <small class="text-muted">Peak Utilization</small>
347
+ </div>
348
+ </div>
349
+ </div>
350
+ <div class="col-md-3">
351
+ <div class="card text-center">
352
+ <div class="card-body">
353
+ <% dead_color = @total_dead > 0 ? "danger" : "success" %>
354
+ <div class="display-6 text-<%= dead_color %>"><%= @total_dead %></div>
355
+ <small class="text-muted">Total Dead</small>
356
+ </div>
357
+ </div>
358
+ </div>
359
+ <div class="col-md-3">
360
+ <div class="card text-center">
361
+ <div class="card-body">
362
+ <% waiting_color = @total_waiting > 0 ? "warning" : "success" %>
363
+ <div class="display-6 text-<%= waiting_color %>"><%= @total_waiting %></div>
364
+ <small class="text-muted">Total Waiting</small>
365
+ </div>
366
+ </div>
367
+ </div>
368
+ </div>
369
+
370
+ <div class="card mb-4">
371
+ <div class="card-header bg-white d-flex justify-content-between align-items-center">
372
+ <h5 class="mb-0">
373
+ <i class="bi bi-database text-info me-2"></i>
374
+ Connection Pool by Error
375
+ <span class="badge bg-info"><%= @errors_with_pool %></span>
376
+ </h5>
377
+ <small class="text-muted"><%== @pagy.info_tag %></small>
378
+ </div>
379
+ <div class="card-body p-0">
380
+ <div class="table-responsive">
381
+ <table class="table table-hover mb-0">
382
+ <thead class="table-light">
383
+ <tr>
384
+ <th width="80">Error</th>
385
+ <th>Error Type</th>
386
+ <th width="110">Utilization</th>
387
+ <th width="60">Busy</th>
388
+ <th width="60">Idle</th>
389
+ <th width="60">Dead</th>
390
+ <th width="70">Waiting</th>
391
+ <th width="70">Pool Size</th>
392
+ <th width="140">Last Seen</th>
393
+ </tr>
394
+ </thead>
395
+ <tbody>
396
+ <% @entries.each do |entry| %>
397
+ <tr>
398
+ <td><%= link_to "##{entry[:error_id]}", error_path(entry[:error_id]), class: "text-decoration-none" %></td>
399
+ <td><code><%= truncate(entry[:error_type].to_s, length: 40) %></code></td>
400
+ <td>
401
+ <% util = entry[:utilization] %>
402
+ <% util_badge = util >= 80 ? "danger" : (util >= 60 ? "warning" : "success") %>
403
+ <div class="progress" style="height: 20px;">
404
+ <div class="progress-bar bg-<%= util_badge %>" role="progressbar" style="width: <%= [ util, 100 ].min %>%;" aria-valuenow="<%= util %>" aria-valuemin="0" aria-valuemax="100">
405
+ <%= util %>%
406
+ </div>
407
+ </div>
408
+ </td>
409
+ <td><%= entry[:busy] %></td>
410
+ <td><%= entry[:idle] %></td>
411
+ <td>
412
+ <% if entry[:dead] > 0 %>
413
+ <span class="badge bg-danger"><%= entry[:dead] %></span>
414
+ <% else %>
415
+ 0
416
+ <% end %>
417
+ </td>
418
+ <td>
419
+ <% if entry[:waiting] > 0 %>
420
+ <span class="badge bg-warning text-dark"><%= entry[:waiting] %></span>
421
+ <% else %>
422
+ 0
423
+ <% end %>
424
+ </td>
425
+ <td><%= entry[:size] %></td>
426
+ <td><%= local_time_ago(entry[:occurred_at]) %></td>
427
+ </tr>
428
+ <% end %>
429
+ </tbody>
430
+ </table>
431
+ </div>
432
+ </div>
433
+ <div class="card-footer bg-white border-top d-flex justify-content-between align-items-center">
434
+ <div>
435
+ <small class="text-muted">
436
+ <i class="bi bi-lightbulb text-warning"></i> High utilization during errors may indicate connection pool exhaustion or long-running queries.
437
+ </small>
438
+ <small class="ms-3">
439
+ <a href="https://guides.rubyonrails.org/configuring.html#configuring-a-database" target="_blank" rel="noopener" class="text-decoration-none">
440
+ <i class="bi bi-book"></i> Database Guide <i class="bi bi-box-arrow-up-right" style="font-size: 0.7em;"></i>
441
+ </a>
442
+ </small>
443
+ </div>
444
+ <div>
445
+ <%== @pagy.series_nav(:bootstrap) if @pagy.pages > 1 %>
446
+ </div>
447
+ </div>
448
+ </div>
449
+ <% end %>
450
+ </div>