solid_observer 0.1.1 → 0.3.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.
Files changed (65) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +63 -0
  3. data/README.md +157 -28
  4. data/app/assets/javascripts/solid_observer/live_poll.js +376 -0
  5. data/app/controllers/concerns/solid_observer/paginatable.rb +17 -0
  6. data/app/controllers/concerns/solid_observer/require_persistence_mode.rb +19 -0
  7. data/app/controllers/concerns/solid_observer/require_solid_queue.rb +19 -0
  8. data/app/controllers/solid_observer/application_controller.rb +69 -0
  9. data/app/controllers/solid_observer/dashboard_controller.rb +79 -0
  10. data/app/controllers/solid_observer/events_controller.rb +50 -0
  11. data/app/controllers/solid_observer/jobs_controller.rb +85 -0
  12. data/app/controllers/solid_observer/storages_controller.rb +12 -0
  13. data/app/helpers/solid_observer/application_helper.rb +95 -0
  14. data/app/helpers/solid_observer/dashboard_helper.rb +39 -0
  15. data/app/models/solid_observer/queue_event.rb +134 -0
  16. data/app/models/solid_observer/queue_metric.rb +1 -1
  17. data/app/presenters/solid_observer/execution_presenter.rb +50 -0
  18. data/app/views/layouts/solid_observer/application.html.erb +470 -0
  19. data/app/views/solid_observer/dashboard/_chart.html.erb +28 -0
  20. data/app/views/solid_observer/dashboard/_live_state.html.erb +20 -0
  21. data/app/views/solid_observer/dashboard/_queue_table.html.erb +34 -0
  22. data/app/views/solid_observer/dashboard/_right_now.html.erb +3 -0
  23. data/app/views/solid_observer/dashboard/_throughput.html.erb +32 -0
  24. data/app/views/solid_observer/dashboard/index.html.erb +113 -0
  25. data/app/views/solid_observer/errors/storage_unavailable.html.erb +27 -0
  26. data/app/views/solid_observer/events/index.html.erb +53 -0
  27. data/app/views/solid_observer/events/show.html.erb +47 -0
  28. data/app/views/solid_observer/jobs/index.html.erb +61 -0
  29. data/app/views/solid_observer/jobs/show.html.erb +71 -0
  30. data/app/views/solid_observer/shared/_empty_state.html.erb +5 -0
  31. data/app/views/solid_observer/shared/_pagination.html.erb +17 -0
  32. data/app/views/solid_observer/shared/_stat_card.html.erb +9 -0
  33. data/app/views/solid_observer/storages/show.html.erb +39 -0
  34. data/bin/quality_gate +95 -0
  35. data/config/routes.rb +17 -0
  36. data/db/migrate/20260424000001_add_composite_indexes_to_queue_events.rb +30 -0
  37. data/lib/generators/solid_observer/install_generator.rb +12 -25
  38. data/lib/generators/solid_observer/templates/initializer.rb.tt +5 -6
  39. data/lib/solid_observer/base_metric.rb +1 -1
  40. data/lib/solid_observer/chart_buffer.rb +83 -0
  41. data/lib/solid_observer/cli/base.rb +2 -2
  42. data/lib/solid_observer/cli/jobs.rb +2 -2
  43. data/lib/solid_observer/cli/status.rb +20 -2
  44. data/lib/solid_observer/cli/storage.rb +41 -40
  45. data/lib/solid_observer/configuration.rb +47 -37
  46. data/lib/solid_observer/correlation_id_resolver.rb +8 -6
  47. data/lib/solid_observer/engine.rb +72 -17
  48. data/lib/solid_observer/params/events_filter.rb +37 -0
  49. data/lib/solid_observer/params/jobs_filter.rb +35 -0
  50. data/lib/solid_observer/queries/events_query.rb +27 -0
  51. data/lib/solid_observer/queries/execution_finder.rb +42 -0
  52. data/lib/solid_observer/queries/job_executions_query.rb +73 -0
  53. data/lib/solid_observer/queue_event_buffer.rb +163 -25
  54. data/lib/solid_observer/queue_stats.rb +165 -19
  55. data/lib/solid_observer/services/cleanup_storage.rb +58 -42
  56. data/lib/solid_observer/services/database_size.rb +86 -0
  57. data/lib/solid_observer/services/flush_event_buffer.rb +31 -15
  58. data/lib/solid_observer/services/install_migrations.rb +49 -0
  59. data/lib/solid_observer/services/record_event.rb +51 -14
  60. data/lib/solid_observer/services/ui_auth_check.rb +65 -0
  61. data/lib/solid_observer/subscriber.rb +15 -8
  62. data/lib/solid_observer/version.rb +1 -1
  63. data/lib/solid_observer.rb +7 -0
  64. data/lib/tasks/solid_observer.rake +10 -2
  65. metadata +55 -1
@@ -0,0 +1,113 @@
1
+ <% content_for :title, "Dashboard" %>
2
+
3
+ <div class="so-dashboard">
4
+ <%# Zone A: Toolbar — range selector, Refresh, Live toggle, help disclosure %>
5
+ <div class="so-dashboard-toolbar" data-so-live>
6
+ <div class="so-dashboard-toolbar__left">
7
+ <label for="so-range" class="so-card__label so-filters__label">Range</label>
8
+ <select id="so-range" name="range" data-so-range-select>
9
+ <% SolidObserver::QueueStats::RANGES.each_key do |key| %>
10
+ <option value="<%= key %>" <%= "selected" if key == @range %>><%= key %></option>
11
+ <% end %>
12
+ </select>
13
+ <button type="button" class="so-btn so-btn--refresh" data-so-refresh aria-busy="false">Refresh data</button>
14
+ <span class="so-toolbar-freshness" data-so-freshness aria-live="polite"></span>
15
+ </div>
16
+ <div class="so-dashboard-toolbar__right">
17
+ <label class="so-toggle so-toggle--pill <%= "so-toggle--on" if @live %>">
18
+ <input type="checkbox" name="live" value="on" <%= "checked" if @live %> data-so-live-toggle>
19
+ <span class="so-toggle__track" aria-hidden="true">
20
+ <span class="so-toggle__thumb"></span>
21
+ </span>
22
+ <span class="so-toggle__label">Live</span>
23
+ <span class="so-toggle__sep" aria-hidden="true"> · </span>
24
+ <span class="so-toggle__cadence" aria-live="polite"><%= @live ? "5s" : "off" %></span>
25
+ <span class="so-toggle__dot" aria-hidden="true"></span>
26
+ </label>
27
+ <div class="so-help-wrapper" data-so-help-wrapper>
28
+ <button type="button" class="so-btn so-btn--help" data-so-help-btn aria-expanded="false" aria-controls="so-help-panel">(?)
29
+ </button>
30
+ <div id="so-help-panel" class="so-help-panel" data-so-help-panel hidden>
31
+ <p>Live mode polls every 5 seconds for current queue state. Throughput and chart data update only on range change or manual refresh.</p>
32
+ </div>
33
+ </div>
34
+ </div>
35
+ </div>
36
+
37
+ <%# Zone B: Right now — live state %>
38
+ <section class="so-dashboard-section" aria-labelledby="so-live-state-heading">
39
+ <div class="so-dashboard-section__header">
40
+ <h2 class="so-dashboard-section__title" id="so-live-state-heading">Right now</h2>
41
+ <span class="so-dashboard-section__meta"><span class="so-badge so-badge--pill so-badge--success"><svg class="so-badge__dot" viewBox="0 0 6 6" aria-hidden="true"><circle r="3" cx="3" cy="3" fill="var(--so-live-marker, var(--so-success))"/></svg></span> current</span>
42
+ </div>
43
+ <%= render "live_state", stats: @stats %>
44
+ </section>
45
+
46
+ <%# Zone C: Throughput in selected range %>
47
+ <% if persistence_mode? && @stats[:available] %>
48
+ <section class="so-dashboard-section" aria-labelledby="so-throughput-heading">
49
+ <div class="so-dashboard-section__header">
50
+ <h2 class="so-dashboard-section__title" id="so-throughput-heading">Throughput in selected range</h2>
51
+ <span class="so-dashboard-section__meta"><span class="so-badge so-badge--pill so-badge--info"><svg class="so-badge__dot" viewBox="0 0 6 6" aria-hidden="true"><circle r="3" cx="3" cy="3" fill="var(--so-range-marker, var(--so-info))"/></svg></span> <span data-so-range-copy><%= range_label(@range) %></span></span>
52
+ </div>
53
+ <%= render "throughput", stats: @stats, range: @range %>
54
+ </section>
55
+ <% end %>
56
+
57
+ <%# Zone D: Charts %>
58
+ <% if @stats[:available] %>
59
+ <section class="so-dashboard-section" aria-labelledby="so-chart-heading">
60
+ <div class="so-dashboard-section__header">
61
+ <h2 class="so-dashboard-section__title" id="so-chart-heading">Charts</h2>
62
+ </div>
63
+ <%= render "chart", range: @range %>
64
+ </section>
65
+ <% end %>
66
+
67
+ <%# Zone E: Per-queue table %>
68
+ <%= render "queue_table", stats: @stats, range: @range %>
69
+
70
+ <%# Zone F: Stability strip %>
71
+ <% if @stats[:available] && persistence_mode? %>
72
+ <section class="so-stability" data-so-zone="stability" aria-labelledby="so-stability-heading">
73
+ <span class="so-stability__label" id="so-stability-heading">Stability</span>
74
+ <%= stability_badge(@stats) %>
75
+ <span class="so-stability__detail"><%= stability_detail(@stats) %></span>
76
+ <%= link_to "View failures →", events_path(event_type: "job_failed"), class: "so-stability__link" %>
77
+ </section>
78
+ <% end %>
79
+
80
+ <%# Zone G: Recent Events %>
81
+ <% if @stats[:available] && persistence_mode? %>
82
+ <% if defined?(@recent_events) && @recent_events.present? %>
83
+ <div class="so-card">
84
+ <div class="so-card__label">Recent Events</div>
85
+ <table class="so-table so-table--card">
86
+ <thead>
87
+ <tr>
88
+ <th>Event</th>
89
+ <th>Job Class</th>
90
+ <th>Queue</th>
91
+ <th>Time</th>
92
+ </tr>
93
+ </thead>
94
+ <tbody>
95
+ <% @recent_events.each do |event| %>
96
+ <tr>
97
+ <td><%= event.event_type.humanize %></td>
98
+ <td><%= event.job_class %></td>
99
+ <td><%= event.queue_name %></td>
100
+ <td><%= time_ago_in_words(event.recorded_at) %> ago</td>
101
+ </tr>
102
+ <% end %>
103
+ </tbody>
104
+ </table>
105
+ </div>
106
+ <% else %>
107
+ <div class="so-card">
108
+ <div class="so-card__label">Recent Events</div>
109
+ <p class="so-dashboard-section__meta so-dashboard-section__meta--empty-events">No recent events yet. Queue activity will appear here after jobs are enqueued, performed, or failed.</p>
110
+ </div>
111
+ <% end %>
112
+ <% end %>
113
+ </div>
@@ -0,0 +1,27 @@
1
+ <% content_for :title, "Storage Unavailable" %>
2
+
3
+ <div class="so-content__header">
4
+ <h1>SolidObserver storage is not reachable</h1>
5
+ </div>
6
+
7
+ <div class="so-card so-card--spaced so-card--accent-danger">
8
+ <p class="so-card__label so-card__label--danger">
9
+ <strong><%= @error_class %></strong>
10
+ </p>
11
+ <pre class="so-pre so-pre--error"><%= @error_message %></pre>
12
+ </div>
13
+
14
+ <div class="so-card so-card--spaced">
15
+ <h2>What to do next</h2>
16
+ <ol>
17
+ <li>Confirm the <code>solid_observer_queue</code> database in <code>config/database.yml</code> is reachable.</li>
18
+ <li>
19
+ Run migrations if you have not yet:
20
+ <pre class="so-pre">bin/rails solid_observer:install:migrations
21
+ bin/rails db:create
22
+ bin/rails db:migrate</pre>
23
+ </li>
24
+ <li>If the database server is temporarily down, reload this page once it recovers.</li>
25
+ <li>Switch to <code>storage_mode: :realtime</code> in <code>config/initializers/solid_observer.rb</code> if you want SolidObserver without a persistence database.</li>
26
+ </ol>
27
+ </div>
@@ -0,0 +1,53 @@
1
+ <% content_for :title, "Events" %>
2
+
3
+ <div class="so-content__header">
4
+ <h1>Events</h1>
5
+ </div>
6
+
7
+ <%= form_tag events_path, method: :get, class: "so-filters" do %>
8
+ <%= select_tag :event_type,
9
+ options_for_select([["All Event Types", ""]] + @available_event_types.map { |t| [t.humanize, t] }, @event_type),
10
+ onchange: "this.form.submit()" %>
11
+
12
+ <%= select_tag :job_class,
13
+ options_for_select([["All Classes", ""]] + @available_job_classes.map { |c| [c, c] }, @job_class),
14
+ onchange: "this.form.submit()" %>
15
+
16
+ <%= select_tag :queue_name,
17
+ options_for_select([["All Queues", ""]] + @available_queues.map { |q| [q, q] }, @queue_name),
18
+ onchange: "this.form.submit()" %>
19
+
20
+ <%= date_field_tag :from, @from&.strftime("%Y-%m-%d"), placeholder: "From date", onchange: "this.form.submit()" %>
21
+ <%= date_field_tag :to, @to&.strftime("%Y-%m-%d"), placeholder: "To date", onchange: "this.form.submit()" %>
22
+
23
+ <%= link_to "Clear Filters", events_path, class: "so-btn" if @event_type.present? || @job_class.present? || @queue_name.present? || @from || @to %>
24
+ <% end %>
25
+
26
+ <% if @events.any? %>
27
+ <table class="so-table so-table--listing">
28
+ <thead>
29
+ <tr>
30
+ <th>Event</th>
31
+ <th>Job Class</th>
32
+ <th>Queue</th>
33
+ <th>Duration</th>
34
+ <th>Time</th>
35
+ </tr>
36
+ </thead>
37
+ <tbody>
38
+ <% @events.each do |event| %>
39
+ <tr>
40
+ <td><%= link_to event.event_type.humanize, event_path(event.id) %></td>
41
+ <td><%= event.job_class %></td>
42
+ <td><%= event.queue_name %></td>
43
+ <td><%= duration_with_semantic(event.duration, event.event_type) %></td>
44
+ <td><%= time_ago_in_words(event.recorded_at) %> ago</td>
45
+ </tr>
46
+ <% end %>
47
+ </tbody>
48
+ </table>
49
+
50
+ <%= render "solid_observer/shared/pagination", page: @page, total_pages: @total_pages %>
51
+ <% else %>
52
+ <%= render "solid_observer/shared/empty_state", message: "No events found matching the criteria" %>
53
+ <% end %>
@@ -0,0 +1,47 @@
1
+ <% content_for :title, "Event ##{@event.id}" %>
2
+
3
+ <div class="so-content__header">
4
+ <h1>Event #<%= @event.id %></h1>
5
+ </div>
6
+
7
+ <div class="so-back-link">
8
+ <%= link_to "← Back to Events", events_path(event_type: params[:event_type], job_class: params[:job_class], queue_name: params[:queue_name], from: params[:from], to: params[:to]), class: "so-btn" %>
9
+ </div>
10
+
11
+ <div class="so-card so-card--spaced">
12
+ <table class="so-table so-details">
13
+ <tbody>
14
+ <tr>
15
+ <td>Event Type</td>
16
+ <td><%= @event.event_type.humanize %></td>
17
+ </tr>
18
+ <tr>
19
+ <td>Job Class</td>
20
+ <td><code><%= @event.job_class %></code></td>
21
+ </tr>
22
+ <tr>
23
+ <td>Queue</td>
24
+ <td><%= @event.queue_name %></td>
25
+ </tr>
26
+ <tr>
27
+ <td>Correlation ID</td>
28
+ <td><code><%= @event.correlation_id || "N/A" %></code></td>
29
+ </tr>
30
+ <tr>
31
+ <td>Duration</td>
32
+ <td><%= duration_with_semantic(@event.duration, @event.event_type) %></td>
33
+ </tr>
34
+ <tr>
35
+ <td>Recorded At</td>
36
+ <td><%= @event.recorded_at.strftime("%Y-%m-%d %H:%M:%S.%3N UTC") %></td>
37
+ </tr>
38
+ </tbody>
39
+ </table>
40
+ </div>
41
+
42
+ <% if @metadata %>
43
+ <div class="so-card so-card--spaced">
44
+ <div class="so-card__label">Metadata</div>
45
+ <pre class="so-pre"><code><%= JSON.pretty_generate(@metadata) %></code></pre>
46
+ </div>
47
+ <% end %>
@@ -0,0 +1,61 @@
1
+ <% content_for :title, "Jobs" %>
2
+
3
+ <div class="so-content__header">
4
+ <h1>Jobs</h1>
5
+ </div>
6
+
7
+ <%= form_tag jobs_path, method: :get, class: "so-filters" do %>
8
+ <%= select_tag :status,
9
+ options_for_select([["All Active", "all_active"], ["Ready", "ready"], ["Scheduled", "scheduled"], ["Claimed", "claimed"], ["Failed", "failed"]], @status),
10
+ onchange: "this.form.submit()" %>
11
+
12
+ <%= select_tag :queue_name,
13
+ options_for_select([["All Queues", ""]] + @available_queues.map { |q| [q, q] }, @queue_name),
14
+ onchange: "this.form.submit()" %>
15
+
16
+ <%= select_tag :job_class,
17
+ options_for_select([["All Classes", ""]] + @available_job_classes.map { |c| [c, c] }, @job_class),
18
+ onchange: "this.form.submit()" %>
19
+ <% end %>
20
+
21
+ <% if @jobs.any? %>
22
+ <table class="so-table so-table--listing">
23
+ <thead>
24
+ <tr>
25
+ <th>ID</th>
26
+ <th>Class</th>
27
+ <th>Queue</th>
28
+ <th>Status</th>
29
+ <th>Created</th>
30
+ <th>Actions</th>
31
+ </tr>
32
+ </thead>
33
+ <tbody>
34
+ <% @jobs.each do |execution| %>
35
+ <% job = execution.job %>
36
+ <% status = execution_status(execution) %>
37
+ <tr>
38
+ <td><%= link_to execution.id, job_path(execution.id, status: status) %></td>
39
+ <td><%= job&.class_name || "N/A" %></td>
40
+ <td><%= job&.queue_name || execution&.queue_name || "N/A" %></td>
41
+ <td><%= status_badge(status) %></td>
42
+ <td><%= time_ago_in_words(execution.created_at) %> ago</td>
43
+ <td>
44
+ <%= link_to "View", job_path(execution.id, status: status), class: "so-btn" %>
45
+ <% if execution.is_a?(SolidQueue::FailedExecution) %>
46
+ <%= button_to "Retry", retry_job_path(execution.id), method: :post, class: "so-btn so-btn--primary", form: { class: "so-form--inline" }, data: { confirm: "Retry job #{execution.id}?" } %>
47
+ <%= button_to "Discard", discard_job_path(execution.id), method: :post, class: "so-btn so-btn--danger", form: { class: "so-form--inline" }, data: { confirm: "Discard job #{execution.id}? This cannot be undone." } %>
48
+ <% end %>
49
+ </td>
50
+ </tr>
51
+ <% end %>
52
+ </tbody>
53
+ </table>
54
+
55
+ <%= render "solid_observer/shared/pagination", page: @page, total_pages: @total_pages %>
56
+ <% else %>
57
+ <%= render "solid_observer/shared/empty_state",
58
+ message: persistence_mode? ?
59
+ "No jobs match this filter. Successfully completed jobs are not stored here — see #{link_to("Events tab", events_path)} for the full job history.".html_safe :
60
+ "No jobs match this filter." %>
61
+ <% end %>
@@ -0,0 +1,71 @@
1
+ <% content_for :title, "Job ##{@execution.id}" %>
2
+
3
+ <div class="so-content__header">
4
+ <h1>Job #<%= @execution.id %></h1>
5
+ </div>
6
+
7
+ <div class="so-back-link">
8
+ <%= link_to "← Back to Jobs", jobs_path(status: @status), class: "so-btn" %>
9
+ </div>
10
+
11
+ <div class="so-card so-card--spaced">
12
+ <table class="so-table so-details">
13
+ <tbody>
14
+ <tr>
15
+ <td>Job Class</td>
16
+ <td><code><%= @job&.class_name || "N/A" %></code></td>
17
+ </tr>
18
+ <tr>
19
+ <td>Job ID</td>
20
+ <td><%= @job&.id || "N/A" %></td>
21
+ </tr>
22
+ <tr>
23
+ <td>Status</td>
24
+ <td><%= status_badge(@status) %></td>
25
+ </tr>
26
+ <tr>
27
+ <td>Queue</td>
28
+ <td><%= @presenter.queue_name || "N/A" %></td>
29
+ </tr>
30
+ <tr>
31
+ <td>Priority</td>
32
+ <td><%= @presenter.priority || "N/A" %></td>
33
+ </tr>
34
+ <tr>
35
+ <td>Created At</td>
36
+ <td><%= @execution.created_at.strftime("%Y-%m-%d %H:%M:%S UTC") %></td>
37
+ </tr>
38
+ <% if @execution.respond_to?(:scheduled_at) && @execution.scheduled_at %>
39
+ <tr>
40
+ <td>Scheduled At</td>
41
+ <td><%= @execution.scheduled_at.strftime("%Y-%m-%d %H:%M:%S UTC") %></td>
42
+ </tr>
43
+ <% end %>
44
+ </tbody>
45
+ </table>
46
+ </div>
47
+
48
+ <% if @execution.is_a?(SolidQueue::FailedExecution) && @execution.error %>
49
+ <div class="so-card so-card--accent-danger">
50
+ <div class="so-card__label so-card__label--danger">Error Details</div>
51
+ <table class="so-table so-details so-table--card">
52
+ <tbody>
53
+ <tr>
54
+ <td>Error Class</td>
55
+ <td><code><%= @execution.error.exception_class %></code></td>
56
+ </tr>
57
+ <tr>
58
+ <td>Message</td>
59
+ <td><%= @execution.error.message %></td>
60
+ </tr>
61
+ </tbody>
62
+ </table>
63
+ <div class="so-card__label">Backtrace</div>
64
+ <pre class="so-pre so-pre--error"><code><%= @execution.error.backtrace&.first(20)&.join("\n") || "No backtrace available" %></code></pre>
65
+ </div>
66
+
67
+ <div class="so-actions">
68
+ <%= button_to "Retry Job", retry_job_path(@execution.id), method: :post, class: "so-btn so-btn--primary", data: { confirm: "Retry job #{@execution.id}?" } %>
69
+ <%= button_to "Discard Job", discard_job_path(@execution.id), method: :post, class: "so-btn so-btn--danger", data: { confirm: "Discard job #{@execution.id}? This cannot be undone." } %>
70
+ </div>
71
+ <% end %>
@@ -0,0 +1,5 @@
1
+ <% local_assigns[:message] ||= "No data available" %>
2
+ <div class="so-empty">
3
+ <div class="so-empty__icon">📭</div>
4
+ <div class="so-empty__message"><%= message %></div>
5
+ </div>
@@ -0,0 +1,17 @@
1
+ <% if total_pages > 1 %>
2
+ <div class="so-pagination">
3
+ <% if page > 1 %>
4
+ <%= link_to "Previous", url_for(request.query_parameters.merge(page: page - 1)), class: "so-btn" %>
5
+ <% else %>
6
+ <span class="so-btn so-btn--disabled" aria-disabled="true">Previous</span>
7
+ <% end %>
8
+
9
+ <span class="so-btn so-btn--static so-pagination__status">Page <%= page %> of <%= total_pages %></span>
10
+
11
+ <% if page < total_pages %>
12
+ <%= link_to "Next", url_for(request.query_parameters.merge(page: page + 1)), class: "so-btn" %>
13
+ <% else %>
14
+ <span class="so-btn so-btn--disabled" aria-disabled="true">Next</span>
15
+ <% end %>
16
+ </div>
17
+ <% end %>
@@ -0,0 +1,9 @@
1
+ <% subtitle = local_assigns[:subtitle] %>
2
+ <% data_key = local_assigns[:data_key] %>
3
+ <div class="so-card">
4
+ <div class="so-card__label"><%= label %></div>
5
+ <% if subtitle.present? %>
6
+ <div class="so-card__subtitle"><%= subtitle %></div>
7
+ <% end %>
8
+ <div class="so-card__value"<%= data_key ? " data-so-card-value=\"#{data_key}\"".html_safe : "".html_safe %>><%= value || 0 %></div>
9
+ </div>
@@ -0,0 +1,39 @@
1
+ <% content_for :title, "Storage" %>
2
+
3
+ <div class="so-content__header">
4
+ <h1>Storage</h1>
5
+ </div>
6
+
7
+ <% if @current_storage %>
8
+ <div class="so-stat-cards">
9
+ <%= render "solid_observer/shared/stat_card", label: "Database Size", value: number_to_human_size(@current_storage.db_size_bytes, precision: 1, significant: false, strip_insignificant_zeros: false) %>
10
+ <%= render "solid_observer/shared/stat_card", label: "Event Count", value: number_with_delimiter(@current_storage.event_count) %>
11
+ <%= render "solid_observer/shared/stat_card", label: "Last Updated", value: time_ago_in_words(@current_storage.recorded_at) + " ago" %>
12
+ </div>
13
+
14
+ <% if @storage_history.any? %>
15
+ <div class="so-card so-card--section">
16
+ <div class="so-card__label">Recent Snapshots</div>
17
+ <table class="so-table so-table--card">
18
+ <thead>
19
+ <tr>
20
+ <th>Time</th>
21
+ <th>Size</th>
22
+ <th>Events</th>
23
+ </tr>
24
+ </thead>
25
+ <tbody>
26
+ <% @storage_history.each do |snapshot| %>
27
+ <tr>
28
+ <td><%= snapshot.recorded_at.strftime("%Y-%m-%d %H:%M") %></td>
29
+ <td><%= number_to_human_size(snapshot.db_size_bytes, precision: 1, significant: false, strip_insignificant_zeros: false) %></td>
30
+ <td><%= number_with_delimiter(snapshot.event_count) %></td>
31
+ </tr>
32
+ <% end %>
33
+ </tbody>
34
+ </table>
35
+ </div>
36
+ <% end %>
37
+ <% else %>
38
+ <%= render "solid_observer/shared/empty_state", message: "No storage information available" %>
39
+ <% end %>
data/bin/quality_gate ADDED
@@ -0,0 +1,95 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ # SolidObserver Quality Gate Suite
5
+ # Runs all non-negotiable gates in sequence.
6
+ # Usage: bin/quality_gate [--skip-appraisal]
7
+ #
8
+ # Individual gates can be run via:
9
+ # bin/quality_gate rspec
10
+ # bin/quality_gate standardrb
11
+ # bin/quality_gate reek
12
+ # bin/quality_gate appraisal
13
+ # bin/quality_gate audit
14
+
15
+ SKIP_APPRAISAL=false
16
+ SINGLE_GATE=""
17
+
18
+ for arg in "$@"; do
19
+ case "$arg" in
20
+ --skip-appraisal) SKIP_APPRAISAL=true ;;
21
+ rspec|standardrb|reek|appraisal|audit) SINGLE_GATE="$arg" ;;
22
+ esac
23
+ done
24
+
25
+ red() { printf '\033[31m%s\033[0m\n' "$*" >&2; }
26
+ green() { printf '\033[32m%s\033[0m\n' "$*"; }
27
+ dim() { printf '\033[2m%s\033[0m\n' "$*"; }
28
+
29
+ run_gate() {
30
+ local label="$1"; shift
31
+ dim "⏳ ${label}..."
32
+ if "$@" > /dev/null 2>&1; then
33
+ green "✅ ${label}"
34
+ return 0
35
+ else
36
+ red "❌ ${label} FAILED — check output above"
37
+ return 1
38
+ fi
39
+ }
40
+
41
+ gate_rspec() {
42
+ run_gate "RSpec" bundle exec rspec
43
+ }
44
+
45
+ gate_standardrb() {
46
+ run_gate "StandardRB" bundle exec standardrb
47
+ }
48
+
49
+ gate_reek() {
50
+ run_gate "Reek" bundle exec reek lib/ app/ db/
51
+ }
52
+
53
+ gate_appraisal() {
54
+ run_gate "Rails 8.0 appraisal" bundle exec appraisal rails-8.0 rspec \
55
+ && run_gate "Rails 8.1 appraisal" bundle exec appraisal rails-8.1 rspec
56
+ }
57
+
58
+ gate_audit() {
59
+ dim "⏳ bundler-audit: updating advisory DB..."
60
+ bundle exec bundler-audit update > /dev/null 2>&1 || true
61
+ run_gate "bundler-audit" bundle exec bundler-audit check
62
+ }
63
+
64
+ run_all() {
65
+ local failed=0
66
+
67
+ gate_rspec || failed=$((failed + 1))
68
+ gate_standardrb || failed=$((failed + 1))
69
+ gate_reek || failed=$((failed + 1))
70
+
71
+ if [ "$SKIP_APPRAISAL" = false ]; then
72
+ gate_appraisal || failed=$((failed + 1))
73
+ else
74
+ dim "⏭️ Skipping appraisal (--skip-appraisal)"
75
+ fi
76
+
77
+ gate_audit || failed=$((failed + 1))
78
+
79
+ echo ""
80
+ if [ "$failed" -eq 0 ]; then
81
+ green "All quality gates passed"
82
+ else
83
+ red "${failed} gate(s) failed"
84
+ return 1
85
+ fi
86
+ }
87
+
88
+ case "$SINGLE_GATE" in
89
+ rspec) gate_rspec ;;
90
+ standardrb) gate_standardrb ;;
91
+ reek) gate_reek ;;
92
+ appraisal) gate_appraisal ;;
93
+ audit) gate_audit ;;
94
+ "") run_all ;;
95
+ esac
data/config/routes.rb ADDED
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ SolidObserver::Engine.routes.draw do
4
+ root "dashboard#index"
5
+ get "poll_data", to: "dashboard#poll_data", as: :poll_data
6
+ get "live_poll.js", to: "dashboard#live_poll", as: :live_poll_script
7
+
8
+ resources :jobs, only: %i[index show] do
9
+ member do
10
+ post :retry
11
+ post :discard
12
+ end
13
+ end
14
+
15
+ resource :storage, only: %i[show]
16
+ resources :events, only: %i[index show]
17
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ class AddCompositeIndexesToQueueEvents < ActiveRecord::Migration[8.0]
4
+ disable_ddl_transaction!
5
+
6
+ def change
7
+ options = concurrent_supported? ? {algorithm: :concurrently} : {}
8
+
9
+ add_index :solid_observer_queue_events,
10
+ %i[job_class recorded_at],
11
+ order: {recorded_at: :desc},
12
+ if_not_exists: true,
13
+ **options
14
+
15
+ add_index :solid_observer_queue_events,
16
+ %i[queue_name recorded_at],
17
+ order: {recorded_at: :desc},
18
+ if_not_exists: true,
19
+ **options
20
+
21
+ remove_index :solid_observer_queue_events, :job_class, if_exists: true, **options
22
+ remove_index :solid_observer_queue_events, :queue_name, if_exists: true, **options
23
+ end
24
+
25
+ private
26
+
27
+ def concurrent_supported?
28
+ connection.adapter_name.match?(/postgres/i)
29
+ end
30
+ end
@@ -24,6 +24,10 @@ module SolidObserver
24
24
  end
25
25
  end
26
26
 
27
+ def add_engine_mount
28
+ route 'mount SolidObserver::Engine, at: "/solid_observer"'
29
+ end
30
+
27
31
  def show_instructions
28
32
  say "\n"
29
33
  print_banner
@@ -34,38 +38,21 @@ module SolidObserver
34
38
  say " 3. Create database: bin/rails db:create"
35
39
  say " 4. Run migrations: bin/rails db:migrate"
36
40
  say " 5. Restart your Rails server"
41
+ say " 6. Visit /solid_observer to access the web dashboard"
37
42
  say "\n"
38
- say "Documentation: https://solid.observer", :cyan
39
- say "GitHub: https://github.com/bart-oz/solid_observer", :cyan
43
+ say "Documentation: https://solid.observer", :red
44
+ say "GitHub: https://github.com/bart-oz/solid_observer", :red
40
45
  say "\n"
41
46
  end
42
47
 
43
48
  private
44
49
 
45
50
  def print_banner
46
- banner = <<~BANNER
47
-
48
- ███████╗ ██████╗ ██╗ ██╗██████╗
49
- ██╔════╝██╔═══██╗██║ ██║██╔══██╗
50
- ███████╗██║ ██║██║ ██║██║ ██║
51
- ╚════██║██║ ██║██║ ██║██║ ██║
52
- ███████║╚██████╔╝███████╗██║██████╔╝
53
- ╚══════╝ ╚═════╝ ╚══════╝╚═╝╚═════╝
54
-
55
- ██████╗ ██████╗ ███████╗███████╗██████╗ ██╗ ██╗███████╗██████╗
56
- ██╔═══██╗██╔══██╗██╔════╝██╔════╝██╔══██╗██║ ██║██╔════╝██╔══██╗
57
- ██║ ██║██████╔╝███████╗█████╗ ██████╔╝██║ ██║█████╗ ██████╔╝
58
- ██║ ██║██╔══██╗╚════██║██╔══╝ ██╔══██╗╚██╗ ██╔╝██╔══╝ ██╔══██╗
59
- ╚██████╔╝██████╔╝███████║███████╗██║ ██║ ╚████╔╝ ███████╗██║ ██║
60
- ╚═════╝ ╚═════╝ ╚══════╝╚══════╝╚═╝ ╚═╝ ╚═══╝ ╚══════╝╚═╝ ╚═╝
61
-
62
- Observe your Solid Stack like a pro! 🔭
63
- v#{SolidObserver::VERSION}
64
-
65
- BANNER
66
-
67
- banner.each_line { |line| say line.chomp, :cyan }
68
- say " ✓ SolidObserver installed successfully!", :green
51
+ say " ┌─ ─┐", :red
52
+ say " ◉ solid_observer", :red
53
+ say " └─ ─┘", :red
54
+ say ""
55
+ say " ✓ SolidObserver installed successfully!", :green
69
56
  end
70
57
  end
71
58
  end